180 KiB
Plan: Maintained, De-Paywalled AltTab Fork (tracks upstream)
This plan describes how to run alt-tab-free as a perpetually maintained fork of lwouis/alt-tab-macos: ship with the Pro paywall neutralized and every feature free, and keep pulling in upstream's bug-fixes and features indefinitely — re-stripping the paywall and re-publishing each cycle with the least possible manual effort and merge-conflict surface.
The goal is fundamentally different from a one-time removal. A one-shot excision optimizes for a
clean end-state; a tracked fork optimizes for repeatable, low-friction re-application against a
moving upstream. Those two objectives pull in opposite directions, and this plan resolves that
tension decisively in favor of a thin, isolated patch. It supersedes
docs/PLAN-republish-free.md (the one-shot "full excision" approach) for
the tracked-fork goal; that document remains the reference for GPL/ethics (§A) and for a from-scratch
clean removal if the fork ever abandons upstream tracking.
TL;DR
- The entire paywall was introduced by lwouis in one commit,
9147a4a8/ v11.0.0. In the 34 commits upstream has shipped since, the four feature-gate chokepoint files have had zero edits. A patch that lands on those dormant lines stays merge-clean essentially forever. - De-paywall with 3 small body-suppress edits in 3 cold files (
computeState() → return .pro, plus twoif false { … }body wraps — theif falseform is required by warnings-as-errors, §2) — no deletions, no edits to any hot or warm file. - Scope the "fails loudly" property honestly (do not over-trust the compile gate). The forced
return .prohard-breaks the build only on a signature/enum refactor ofLicenseState/computeState. A silent semantic relock that still compiles — a newLicenseStatecase, the gate re-pointed offstate, a server-driven entitlement check, the live-state producer moved out ofcomputeState()— is caught NOT by the compiler and NOT by Guard A (the gate-consumersProFeature/PreferenceDefinitionare outside theunit-teststarget), but only by the §4.4 behavioral guards + per-release feature-unlock check and the §6.2 manual gate-consumer review (§2). The "fails loudly" headline applies to the refactor class only; the rest is covered by the behavioral/manual layers, not the compile/Guard-A gate.src/pro/stays physically present soproject.pbxproj(the most merge-hostile file) is never touched by the de-paywall patch and on every per-merge cycle. One edit (computeState() → return .pro) opens every gate in the tree at once. (The anti-relock backstop is added as new test methods inside the already-memberedLicenseManagerTests.swift, so it too needs zero pbxproj edits — see §4.4. The onlyproject.pbxprojtouch in the whole plan is a one-time, optional one if the owner insists on a separate test file; the default path avoids it entirely.) - Do NOT delete
src/pro/. Deletion forces ~99 edits toproject.pbxprojplus surgery on the 251-commitApp.swiftspine — guaranteeing conflicts on every future merge. Inertia is cheaper than excision here, and the churn data proves it. - Identity lives in fork-owned or content-overwritten files (a committed
config/local.xcconfig, a replacedREADME.mdandapp.icns, a few dormantInfo.plistlines) so rebranding adds near-zero recurring conflict surface. - Git model: merge (not rebase/patch-queue). Trigger: a daily cron (recommended: an EXTERNAL
scheduler hitting
repository_dispatch, not the in-reposchedule:that auto-disables after 60 days — §4.2) that opens a sync PR. Backstop: TWO layers — (i) pre-merge guards — an anti-relock unit test (mock-injectedLicenseManager, appended to the existingLicenseManagerTests.swift, using the existingMockClock), a target-independent CI grep (Guard B), and a conflict-marker grep (Guard C), run by a SEPARATE fork-ownedguard.ymlworkflow onpull_requestso they survive even a carelessci_cd.ymlconflict resolution (§4.4) — plus a per-release feature-unlock acceptance check on the BUILT app (the gate-consumers are not unit-testable, §4.4); and (ii) the same Guard B + Guard C greps wired intoci_cd.ymlas the FIRST push-job steps — beforerun_tests.shand therefore beforeupdate_appcast.sh(Sparkle sign),semantic-release(tag push), andsoftprops/action-gh-release— so a relock or a still-conflicted tree aborts the job before the tag is pushed or the binary signed, even if the PR gate is missing or misconfigured. (A grep placed only just beforeaction-gh-releaseis too late — by then the tag is already pushed and the binary signed; §4.4.) - BLOCKING bootstrap requirement (§4.2): the sync cron MUST author its branch push + PR with a
workflow-re-triggering credential (a GitHub App installation token / fork PAT,
SYNC_BOT_TOKEN), NOT the defaultGITHUB_TOKEN— by GitHub's recursion rule aGITHUB_TOKEN-authored PR fires NOpull_requestchecks, soguard.yml/ci_cd.yml's pre-merge gate would silently never run. - A first-class, mandatory bootstrap deliverable (§4.2/§6.1): add a
pull_request:trigger to the fork'sci_cd.ymlAND gate every release-side step (includingcommitlintanddetermine_next_version, which misbehave on PR events — §4.2) so the PR check is cleanly green/red — with an acceptance test that a deliberately-relocked branch shows a RED PR check before merge. Upstream'sci_cd.ymlfires only on push-to-master and is the sole workflow, so without this the guards would run only post-merge; the layered design above means a relock still cannot ship even in that degraded state. - One-time bootstrap chores the steady-state runbook does NOT repeat (do these once before first
release): re-sign the bundled Sparkle helpers under the fork's Developer ID, sweep the residual
hardcoded
AltTabbrand strings (l10n + a handful ofsrc/*.swiftliterals), repointApp.repository/APP_NAME, and wirepermissions: contents: write+ aproductionEnvironment. None of these recur per merge, but every one is load-bearing for a build that notarizes, doesn't infringe the trademark, and actually releases. - Steady-state effort, stated honestly with the l10n tax folded in: ~5–15 min of PR review when no
swept file moves; +10–20 min and a
genstringsre-run whenever a sync touches one of the ~18 trademark-sweptsrc/*.swiftfiles or the.stringsfiles (a baseline recurrence, not a rare edge case —ensure_generated_files_are_up_to_date.shhard-fails on any uncommitted regenerated diff, §4.5); and only a rare upstream refactor ofsrc/pro/internals costs ~30–90 min, with the merge conflict + test making a wrong resolution impossible to ship. Plus a separate periodic-overhead bucket (yearly cert/notarization renewal, per-Sparkle-bump helper re-sign — §4.6), independent of merge cadence. The unqualified "5–15 min" number describes only the no-swept-file-moved cold path; do not read it as the all-in steady state.
Decision summary
| Decision | Choice | One-line rationale |
|---|---|---|
| Git model | Merge upstream release tags into master (not rebase / patch-queue) |
The patched lines are dormant upstream, so a rebase's linear-history benefit buys nothing while multiplying replay cost; merge surfaces a chokepoint collision exactly once, loudly. |
| Patch mechanism | 3 small body-suppress edits in 3 cold files (computeState()→.pro; syncLicenseCookie + AppCenterCrash.init bodies wrapped in if false { … }); leave src/pro/ intact |
One edit at the single state-producer opens every gate; zero per-merge edits to hot (App.swift, project.pbxproj) or warm (UI) files; fails loudly (compile break) on a signature/enum refactor — a silent semantic relock (new enum case, gate re-pointed off state, producer moved) compiles clean and is caught only by the §4.4 behavioral guards + the §6.2 gate-consumer review, not the compile (§2). (The de-paywall edits themselves need zero pbxproj edits, period; the anti-relock test is appended to an already-membered file so it adds none either.) |
| Identity approach | Overlay in fork-owned / content-overwritten files — commit config/local.xcconfig, replace README.md/app.icns, repoint/remove .github/FUNDING.yml, edit ~3 dormant Info.plist lines; let the DOMAIN cascade repoint everything else |
Identity diff avoids files upstream churns; base.xcconfig/Endpoints.swift/project.pbxproj stay untouched. |
| CI trigger | Scheduled cron (upstream_sync.yml) opens a reviewable sync PR, authored with a workflow-re-triggering SYNC_BOT_TOKEN (NOT the default GITHUB_TOKEN); release fires on merge to master via the existing ci_cd.yml |
No upstream cooperation needed; a human gates each release. Two BLOCKING durability requirements (§4.2): (1) the bot token must re-trigger checks or the PR gate never fires; (2) prefer an EXTERNAL scheduler over the in-repo schedule:, which GitHub disables after 60 days of repo inactivity — pair with a two-trigger liveness alert. |
| Release gate | Mandatory bootstrap re-architecture of ci_cd.yml — add pull_request: trigger, gate all release-side steps under if: github.event_name=='push', PLUS a separate fork-owned guard.yml on pull_request — and Guard B (relock) + Guard C (conflict-marker) greps as the FIRST steps of ci_cd.yml's push job — before run_tests.sh/update_appcast.sh(Sparkle sign)/semantic-release(tag push)/action-gh-release |
First-class deliverable, not a footnote: upstream's ci_cd.yml is push-only and the sole workflow, so the guards must be re-wired to gate pre-merge; the in-ci_cd.yml greps (placed FIRST, not merely before action-gh-release — which runs after the tag is pushed, §4.4) + separate guard.yml make "a relock or conflicted tree can never ship — the job aborts pre-tag-push" robust to a missing/misconfigured trigger (§4.2/§4.4). Note the gate consumers aren't unit-testable, so a per-release built-app feature-unlock check is also required (§4.4). |
| Maintenance off-ramp | Pre-committed trip-wire to abandon tracking if the chokepoints churn repeatedly (§0.1); plus a maintainer-disengagement posture (§0.2) | A low-effort tracked fork has a defined failure point; when the 3-edit patch stops being cheap, fall back to excision (PLAN-republish-free.md) or pin to pre-paywall v10.12.0 rather than fighting every merge. A lapsed maintainer degrades to "stale, not broken" (§0.2). |
Realistic all-in steady-state effort (read this, not just the "5–15 min" headline): a typical sync is
~15–35 min once the l10n tax is folded in (PR review + any "keep ours" identity/CI conflicts + a
genstrings re-run whenever a swept file moved + the per-release feature-unlock acceptance check, §4.4);
a rare chokepoint refactor is ~30–90 min; and a separate periodic-overhead bucket (quarterly
cert/notarization/key/token review, per-Sparkle-bump helper re-sign — §4.6) sits outside the per-merge clock.
The bare "5–15 min" is only the cold path with no swept file moved.
0. Why continuous tracking, and why NOT full excision
Why continuous tracking at all (vs. lazy on-demand resync) — a conscious cost decision. This fork carries zero unique feature commits: per
PLAN-republish-free§B, the only delta from upstream IS the paywall removal. So the fork gains nothing of its own from upstream — it tracks purely to inherit upstream's bug-fixes, security fixes, and macOS-compatibility work. That makes a lighter posture defensible: resync only when upstream ships something the owner actually wants, skipping the daily cron + PR-gate +SYNC_BOT_TOKEN+guard.ymlapparatus entirely and paying a larger one-off re-strip cost occasionally. This plan chooses continuous tracking anyway, for one reason: unattended pickup of security/bug fixes. A window-switcher that uses private SkyLight/AX APIs and ships a Sparkle auto-updater is exactly the kind of app where a silently-skipped upstream security fix is a real user-harm risk, and a human-driven "resync when I notice something" loop reliably drifts. The standing operational cost (the §4.6 credential bucket, the cron-durability machinery, the macOS CI minutes per sync) is the conscious price of not having to remember to look. If the owner does not value unattended fix-pickup, the lighter on-demand posture is legitimate — in that case, pin to pre-paywallv10.12.0(§0.1 option 2) or run the one-shot excision (PLAN-republish-free.md) and resync by hand. The rest of this plan assumes the continuous-tracking choice has been made deliberately.
Why NOT full excision (the merge-surface argument)
docs/PLAN-republish-free.md §B recommends Strategy 2 — full excision
(delete src/pro/, untangle every consumer). That is the right call for a one-and-done republish:
the end-state is clean, with no dead paywall code in the tree. But it is the wrong call for a fork
that must merge upstream forever, for one concrete reason rooted in the churn data:
alt-tab-macos.xcodeproj/project.pbxprojis the single most merge-hostile file in the repo: 240 commits all-time, 10 since v11.0.0, and ~99 lines that referencepro/,License,ProFeature, orPreferenceDefinition. Deletingsrc/pro/means deleting all ~99 of those build-file / group / Sources entries — and every upstream pbxproj change (it churns roughly monthly) would then collide with our deletions.src/App.swiftis the wiring spine (5 commits since v11.0.0; historically the hottest source file). Excision requires severing the license fan-out (≈448–460), theonAction/onAppLaunchCompletehooks (≈428–429), the Day-1 first-launch entanglement (≈206–207), and the custom-URL activate handler. Every one of those edits sits on a high-traffic file.- Excision also drags in the warm upsell-UI files (
Menubar.swift,SettingsWindow.swift,Preferences.swift), each recently touched.
0.1 Off-ramp — when to abandon tracking (pre-committed trip-wire)
This plan's whole economic case is that the 3-edit patch lands on dormant lines and stays cheap. That premise can degrade. Define the exit now, before it's needed, so a deteriorating upstream doesn't quietly turn the "5-minute review" into a recurring fight:
- Trip-wire A — repeated chokepoint churn. If upstream refactors
computeState()/ the chokepoint signatures so the de-paywall patch must be re-applied by hand on ≥3 syncs within a rolling 12 months, the dormancy premise (§1) has failed for this fork's lifetime. Make this tally auto-observable, not a manual count: when a sync's conflict touches a chokepoint file, the runbook (§6.2 step 4) labels that PRchokepoint-refresh(in addition tosync,conflict), so the trip-wire is a single query rather than a memory exercise:gh pr list --state merged --label chokepoint-refresh --search 'merged:>=<12-months-ago>'— if it returns ≥3, the trip-wire has fired. Auto-apply the label from CI, don't rely on the human remembering: the per-sync chokepoint-churn count (§1, measured-signal note) is nonzero exactly when a chokepoint file changed in the merge range, so haveguard.yml/the sync workflowgh pr edit --add-label chokepoint-refreshwhen that count > 0 (or whengit diff --name-only --diff-filter=Ushows a chokepoint conflict). The manual §6.2-step-4 label is then a backstop, not the primary mechanism — trip-wire A's tally cannot be defeated by a forgotten label. - Trip-wire B — structural
src/pro/reorg. If a single upstream release moves/renamessrc/pro/such that the patch no longer applies and the conflict spans >1 chokepoint file, or if any sync newly couples a hot file (App.swift,project.pbxproj) into the de-paywall surface. - Trip-wire C — commercial-surface expansion. If the periodic re-audit (§6.2) finds upstream has added a NEW gate/endpoint/telemetry path the current 3-edit + 2-test + 1-grep target does not cover.
- Trip-wire D — functional / compatibility-breaking sync (NOT a paywall event). The fork's job is not
only "keep Pro free" but "keep the app working" across upstream's normal functional evolution — and the
Guards A/B/C + the §4.4 feature-unlock check prove only that Pro is free, not that the app still
launches and works on the supported macOS range. Trip this branch when a merge touches any of:
MACOSX_DEPLOYMENT_TARGET(config/base.xcconfig:5, currently10.13; drivesLSMinimumSystemVersionviaInfo.plist:32-33and Sparkle'sminimumSystemVersion) — a raised macOS floor changes who can run/auto-update the fork;Info.plistpermission/entitlement keys (a newNS*UsageDescription, a new entitlement, a hardened-runtime change) — affects Gatekeeper/notarization and first-run permission UX;PreferencesMigrations.swift(a new migration step) — can interact with the §3.1(b)remembered*migration if the fork reused an existing bundle id;- a major upstream version bump (e.g. v11→v12) that the frozen
v100.0.0fork offset (§4.3) must still present sanely to users and to Sparkle's numeric ordering. This is a normal, expected event for a 240+-commit-spine app; it is NOT an off-ramp to abandon tracking by itself — it is a flag to run the §6.2 compatibility branch (smoke/launch test on the macOS floor, system- requirements review, and an explicit owner go/no-go on a major bump) before releasing. It only escalates to a strategy change if a compatibility break is one the fork cannot reasonably carry.
On any trip-wire, switch strategies rather than absorbing the cost merge-after-merge:
- Fall back to excision — execute
docs/PLAN-republish-free.md(fullsrc/pro/deletion) once, accept the one-time cost, and thereafter track upstream against the excised tree; OR - Pin to pre-paywall
v10.12.0(commit317a485b, the last tag with no paywall — seedocs/AUDIT.md§1) and cherry-pick only the upstream fixes the fork actually wants. This trades post-v11 features for a zero-paywall base, eliminating the re-strip loop entirely.
Reaching a trip-wire is the explicit owner-decision point; until then this plan's inert-patch strategy stands.
0.2 The OTHER off-ramp — maintainer disengagement (human-availability assumption)
§0.1 handles a deteriorating upstream. The loop has no off-ramp for a lapsed maintainer — yet every gate in this plan (PR review, conflict resolution, the per-release feature-unlock acceptance check) assumes a human shows up. State the assumption and a posture for when it fails:
- The whole loop degrades gracefully to "no new releases," not to "a broken/relocked release." Because the release fires only on a human-merged PR (§4.2) and Guards A/B/C fail-closed (§4.4), a disengaged maintainer simply means sync PRs pile up unmerged and the last good release keeps serving via Sparkle — the fork goes stale, it does not ship a regression. This is the intended safe-failure mode; say so.
- Escalating liveness alert. The §4.2 liveness alert (no sync PR in ~45 days / oldest open PR > ~14 days) is also the disengagement detector — wire it to escalate (e.g. notify a backup owner) rather than silently re-open the same issue.
- Document "buildable from source by a successor." Keep the bootstrap (§6.1) and runbook (§6.2) complete enough that a new maintainer can clone the fork and cut a release without tribal knowledge — the GPL source is already public (§5), so the project can outlive any single owner. Note that the Sparkle private key and signing cert are the one piece a successor cannot reconstruct (§4.6); a true hand-off must transfer those secrets, or the successor ships under a new identity (a fresh install for users).
A thin leave-src/pro-inert patch (this plan) touches project.pbxproj zero times per merge,
App.swift zero times per merge (one one-time cold-line repository repoint at bootstrap — §3.5),
and the warm UI files zero times. The dead paywall code physically remaining in the
tree is the price we happily pay to keep the recurring merge a 5-minute review instead of a recurring
fight with the two hottest files in the project. (NB: docs/AUDIT.md §5/§6 recommend the opposite —
full deletion of src/pro/ — because that audit optimizes for a clean one-shot end-state, the same goal as
PLAN-republish-free.md. The leave-src/pro-inert strategy is this plan's synthesis for the
tracked-fork goal, not a recommendation carried over from the audit; the audit's own churn data — not its
conclusion — is what supports it.) For the tracked-fork goal, leaving src/pro/ inert is therefore not a
compromise but strictly superior, even though it diverges from the audit's deletion recommendation.
1. Merge-conflict risk (churn analysis)
Decisive fact: the entire paywall — src/pro/, the PreferenceDefinition read-gate, the api/
files, and the DOMAIN/signing identity edits in config/ — was introduced by lwouis in one commit,
9147a4a8 / v11.0.0 ("feat: introducing alt-tab pro!"). In the 34 commits upstream has shipped
since (through HEAD 9fadf36b / v11.3.0), the four chokepoint files have had zero subsequent edits.
Verified with git:
$ git rev-list --count v11.0.0..HEAD # 34
$ git rev-list --count v11.0.0..HEAD -- src/pro/license/LicenseManager.swift # 0
$ git rev-list --count v11.0.0..HEAD -- src/pro/ProFeature.swift # 0
$ git rev-list --count v11.0.0..HEAD -- src/preferences/PreferenceDefinition.swift # 0
$ git rev-list --count v11.0.0..HEAD -- src/pro/scheduling/ProTransitionManager.swift # 0
Pickaxe (-S) on the exact tokens isProLocked, attemptUse, isFreePassSessionActive,
freeEquivalent returns only the introducing commit for those files. The two later commits that move
those tokens anywhere in the tree (60cca89b, 3059dc23, both 2026-05-28) touch only consumers
(src/switcher/ShortcutAction.swift, TilesView.swift) and one comment line in SettingsWindow.swift
— never the chokepoint definitions. A patch confined to those four cold files therefore lands on lines
upstream has never re-touched.
The hazards are not the chokepoints; they are the hot/warm files a deletion-based approach would be forced to edit:
| File | All-time | Since v11.0.0 | Heat | Role / patch disposition |
|---|---|---|---|---|
src/pro/license/LicenseManager.swift |
1 | 0 | cold | Primary chokepoint. computeState() (176-190) is the single state producer; isProLocked (67-72), isProAvailable (62). PATCH HERE. |
src/pro/ProFeature.swift |
1 | 0 | cold | Hard-gated runtime features via attemptUse() (74-83). Goes inert via the flip — no edit needed. |
src/preferences/PreferenceDefinition.swift |
1 | 0 | cold | Degradable-prefs gate read() (32-44). Inert via the flip — the guard … isProLocked (line 34) is false, so read() returns the user's stored value unrestricted (the gate opens; Pro values become selectable — it does not auto-seed them). |
src/pro/scheduling/ProTransitionManager.swift |
1 | 0 | cold | Nag scheduler. Never arms once state is .pro — no edit needed. |
src/pro/license/LicenseCookie.swift |
1 | 0 | cold | syncLicenseCookie() writes a cookie on alt-tab.app. PATCH (1 line) to silence it. |
src/vendors/AppCenterCrashes.swift |
1 | 0 | cold | AppCenterCrash.init() starts AppCenter telemetry (line 20). PATCH (1 line) so it never starts. |
config/base.xcconfig |
20 | 0 | cold | Bundle id (:4), DOMAIN/API_DOMAIN (:20-21). Dormant. Do not edit — override via local.xcconfig. |
config/release.xcconfig |
9 | 0 | cold | CODE_SIGN_IDENTITY (:5). Override via local.xcconfig. |
Info.plist |
23 | 0 | cold | NSPrincipalClass (key:36/value:37 — do NOT edit, §3.4), SUPublicEDKey (key:61/value:62 — edit value), Domain/ApiDomain (key:75/value:76, key:77/value:78), URL scheme. Only the SUPublicEDKey value is edited for identity — merge-safe (the domains repoint via the xcconfig cascade, no Info.plist edit). |
src/api/Endpoints.swift |
1 | 0 | cold | All URLs derived from DOMAIN/API_DOMAIN. Repoints via cascade — no edit. |
src/api/Secrets.swift |
1 | 0 | cold | Reads AppCenterSecret from bundle. Dead once telemetry off. No edit. |
src/Menubar.swift |
3 | 2 | warm | "Get Pro"/"My Account" items, badge. Goes quiet via the flip. Do not edit. |
src/preferences/Preferences.swift |
156 | 2 | warm | effective* getters read through the gate; auto-return Pro values once flipped. Do not edit. |
src/preferences/settings-window/SettingsWindow.swift |
14 | 2 | warm | Upgrade button/tab, Pro badges. Stays hidden via the flip. Do not edit. |
.github/workflows/ci_cd.yml |
20 | 1 | warm→hot (fork-owned) | Sole CI workflow. Fork must own it; accept manual reconciliation on upstream CI changes. |
src/App.swift |
251 | 5 | hot | Wiring spine. Paywall fan-out becomes inert via the flip. No per-merge edit. ONE one-time cold-line bootstrap edit: repository (line 16) → fork URL (GPL §6 source pointer — §3.5). |
alt-tab-macos.xcodeproj/project.pbxproj |
240 | 10 | hot | Most merge-hostile; ~99 pro-references. Leaving src/pro/ in place keeps this at 0 per-merge edits, and the anti-relock backstop adds none (it lives in the already-membered LicenseManagerTests.swift). Do not edit per merge. |
Verdict: A thin patch confined to the four cold chokepoint files (plus optional cold identity edits)
stays merge-clean indefinitely. Deletion would make project.pbxproj the #1 conflict source. The
churn data unambiguously supports the leave-src/pro-inert strategy.
Make the dormancy premise a MEASURED signal, not a one-time assertion. This whole plan's economics rest on "the chokepoint files stay dormant upstream" — true through v11.3.0, but it can silently stop being true. Turn it into a per-sync number surfaced ON the sync PR: have CI (or the sync workflow) run
git rev-list --count <last-synced-tag>..<new-tag> -- src/pro/license/LicenseManager.swift src/pro/ProFeature.swift src/preferences/PreferenceDefinition.swift src/pro/scheduling/ProTransitionManager.swift src/pro/license/LicenseCookie.swift src/vendors/AppCenterCrashes.swiftand post the count as a PR comment / check. A nonzero count is the early warning that dormancy is eroding (and feeds §0.1 trip-wire A) — surfaced before the maintainer resolves, not discovered as a surprise conflict. This converts the load-bearing premise from "we assert it holds" to "we measure it every sync."
2. The minimal de-paywall patch
Mechanism: force LicenseManager.computeState() to return .pro
There is exactly one place in the codebase where a "real" LicenseState is produced from data:
LicenseManager.computeState() (src/pro/license/LicenseManager.swift:176-190). Every entry point
that sets live state routes through it:
initialize()→state = computeState()(line 92), called once at launch fromApp.swift:~460.refreshState()→computeState()(line 100), called by Menubar/SettingsWindow before readingstate.- The only network-driven reassignment (
revalidateWithServer, line 223) also callscomputeState()— and it is itself unreachable, early-returning unless a keychain license key exists (line 210), which never happens in a free build.
Because isProAvailable (line 62) and isProLocked (lines 67-72) are pure functions of state, and
ProFeature.attemptUse() / PreferenceDefinition.read() / ProTransitionManager all consult them,
one edit at computeState() opens every gate in the tree simultaneously — no need to touch the
four-way chokepoint funnel individually.
Why this beats the alternatives:
- vs. editing
isProLocked → return false:isProLockedalone leavesisProAvailablereading fromstate(still.trialExpired), soProFeature.attemptUse()line 75 (if LicenseManager.shared.isProAvailable { return true }) would not short-circuit; you'd also have to patchattemptUseandLicenseState.isProAvailable. Forcingstateis strictly smaller (1 edit vs. 2-3). - vs. a
#if FREE_BUILDcompilation flag: that adds aSWIFT_ACTIVE_COMPILATION_CONDITIONSline in an identity-bearing xcconfig plus#ifblocks — more files, more conflict surface. The single-line return is smaller and equally cold. - Fails loudly if upstream refactors the SIGNATURE:
computeState()returns the non-optionalLicenseStateenum. If lwouis renames the enum, splits it, or changes the signature, the forcedreturn .prostops compiling — a hard build break the republish pipeline catches immediately, rather than silently re-locking. This fail-closed-to-the-fork property is exactly what the perpetual-rebase workflow wants.
Scope this property honestly (it is narrower than "fails loudly, period"). The compile-break catches only signature/enum refactors of
LicenseState/computeState. It does NOT catch a silent semantic relock that still compiles cleanly: a NEWLicenseStatecase,isProAvailable/isProLockedbeing rewritten to consult something other thanstate, a per-feature or server-driven entitlement check added alongside the gate, or the live-state producer being moved out ofcomputeState(). Those compile fine and would re-arm the paywall. They are caught — if at all — only by the behavioral guards (§4.4), and even Guard A is structurally limited toLicenseManager-level behavior (ProFeature/PreferenceDefinition/ the gate consumers are NOT in theunit-teststarget). The backstop for this class of relock is therefore the §6.2 manual-review step: on any sync that touchesProFeature/PreferenceDefinition/ShortcutAction/TilesView(the gate consumers, which upstream demonstrably churns —60cca89b/3059dc23), the reviewer MUST confirm the gates still consultstate.
Do NOT individually edit isProLocked, ProFeature.attemptUse, or PreferenceDefinition.read —
they become inert automatically once state == .pro:
isProLocked→false(line 69, the.procase).isProAvailable→true→attemptUse()returnstrueat line 75 before consultingProTransitionManager.PreferenceDefinition.read()→ theguard … isProLockedat line 34 is false, so it returns the user's stored value unrestricted. The gate simply opens — Pro values become freely selectable and are never downgraded to the free equivalent. (It does not auto-set prefs to Pro; it stops forcing them to Free.) Zero edits.
Editing those three adds three diff hunks for no behavioral gain and three more future collision points.
Build-warning caveat (verified — applies to all three edits):
config/base.xcconfig:7setsSWIFT_TREAT_WARNINGS_AS_ERRORS = YES. Areturnplaced above live code emits a "code after 'return' will never be executed" warning, which becomes a build error. Therefore do NOT use a bare early return — wrap the original body inif false { … }(so it is syntactically reachable, not flagged) and putreturn .proafter it. This keeps each edit to a single small hunk that survives upstream edits to the dead body. The per-edit code below shows the safe form explicitly.
Edit 1 (PRIMARY — cold): force the gate open
File: src/pro/license/LicenseManager.swift (cold: 1/0). Location: body of computeState()
(signature at line 176; first body statement at line 177).
The naïve form below DOES NOT COMPILE. Because
base.xcconfig:7setsSWIFT_TREAT_WARNINGS_AS_ERRORS = YES, a barereturn .proplaced above the existing body emits "code after 'return' will never be executed" → a build error. The illustrative-only naïve form is:// ILLUSTRATIVE ONLY — will NOT build (warning-as-error on the dead code below): func computeState() -> LicenseState { return .pro if keychain.value(account: Self.keychainKeyAccount) != nil { ... } // <- "code after return" → error return computeTrialState() }Ship the
if false { … }wrap instead (single hunk, no unreachable-code warning, survives upstream edits to the wrapped body):func computeState() -> LicenseState { // alt-tab-free [depaywall]: paywall neutralized at the single live-state producer — see docs/AUDIT.md if false { if keychain.value(account: Self.keychainKeyAccount) != nil { ... } return computeTrialState() } return .pro }
Diff: a few lines (one if false { open + one } close around the original body, plus the forced
return .pro). This alone delivers all Pro features free, silences nags (see Edit 4), and kills license
revalidation network calls. Behaviorally it is the only required change. (computeState() is the single
live LicenseState producer: forcing it makes isProLocked → false (line 69), isProAvailable → true
(LicenseState.swift:7), ProFeature.attemptUse() → true (ProFeature.swift:75), and
PreferenceDefinition.read() return the stored value — all without further edits.)
Edit 2 (side-effect — cold): silence the launch-time cookie write
File: src/pro/license/LicenseCookie.swift (cold: 1/0). syncLicenseCookie(...) is a free function
(signature at line 5; the guard body starts at line 6). With state forced to .pro, App.swift's
onStateChanged hook still calls syncLicenseCookie(state: .pro) once on launch, writing a
license=pro cookie on the alt-tab.app domain (lines 13-21). Neutralize at the top of the function —
do not touch the App.swift call site (hot file).
Same warnings-as-error trap. A bare
returnabove the body trips "code after 'return'". Use theif false { … }wrap (or#if falsearound the body) so the diff is a single safe hunk:func syncLicenseCookie(state: LicenseState) { // alt-tab-free [depaywall]: no license cookie on the upstream domain if false { guard let host = URL(string: Endpoints.website)?.host else { return } ... } }
Diff: 2 lines (if false { + }).
revalidateWithServer (LicenseManager.swift:209) is genuinely dead — it early-returns unless a keychain
license key exists (line 210), which never happens in a free build, so validate/MachineFingerprint
behind it never fire. But RemoteLicenseClient.activate is NOT unreachable on a state argument: the
custom-URL handler (App.swift:465-481) is gated only on the URL scheme
(url.scheme == App.bundleIdentifier, url.host == "activate") — it is not gated on LicenseState.
Any <bundleid>://activate?license_key=… invocation (the scheme stays registered via
Info.plist CFBundleURLSchemes = $(PRODUCT_BUNDLE_IDENTIFIER)) reaches
LicenseManager.shared.activate → RemoteLicenseClient POST to licenseApiBaseUrl regardless of trial/pro
state — and that POST sends the machine hardware UUID: RemoteLicenseClient.swift:18 includes
"fingerprint": MachineFingerprint.get(...), and MachineFingerprint.swift:13 reads kIOPlatformUUIDKey
(verified). So this is a live hardware-identifier exfiltration surface, not dead code.
DEFAULT (promoted) — neutralize the
activatehandler itself, independent of whereAPI_DOMAINpoints. Add a 1-line early return inhandleCustomUrlthat drops/ignores theactivatehost (or returns before theLicenseManager.shared.activate(...)call). This kills the fingerprint POST regardless of theAPI_DOMAINvalue, so it is robust even when feedback (§3.4) needsAPI_DOMAINto be a routable fork-owned host. This is a 4thsrc/App.swiftedit — butApp.swiftline 16 is already a one-time bootstrap edit (§3.5), so this rides along as a one-time bootstrap neutralization (not per-merge), and unlike theAPI_DOMAINrepoint it does NOT depend on the feedback decision. This plan adopts the handler neutralization as the default for theactivatesurface.An
API_DOMAINrepoint alone does NOT makeactivatesafe. PointingAPI_DOMAINat a non-routable host happens to dead-end the POST, but (i) it is the WRONG default if feedback option (a) is chosen — §3.4(a) requiresAPI_DOMAINto be routable and fork-owned, and in that branch a craftedactivatedeep link POSTs the machine fingerprint to the fork's own backend (the non-routable option's protection silently re-arms into exfiltration to the fork); and (ii) it overloads one knob with two opposite jobs. So theAPI_DOMAINvalue is a feedback decision (§3.4), and theactivatefingerprint POST is closed separately by the handler edit above — the two are now decoupled.
WARNING —
API_DOMAINis shared with the feedback feature. A non-routableAPI_DOMAINalso breaksFeedbackWindow(itsfeedbackUrlderives fromAPI_DOMAIN), disabling a user-visible feature. Because theactivatesurface is now closed by the handler edit (above) regardless ofAPI_DOMAIN, you are free to makeAPI_DOMAINroutable for feedback (§3.4 option a) WITHOUT re-arming the fingerprint POST. Choose theAPI_DOMAINvalue purely on the feedback decision (§3.4); do not pick "non-routable" believing it is what protectsactivate— the handler edit is.
Leaving RemoteLicenseClient.swift/MachineFingerprint.swift physically present otherwise keeps
Endpoints.swift, Secrets.swift at 0 edits.
Edit 3 (telemetry — cold): don't start AppCenter
File: src/vendors/AppCenterCrashes.swift (cold: 1/0). Info.plist:37 sets
NSPrincipalClass = AppCenterApplication (a vendored NSApplication subclass), and the app's App class
subclasses it (App.swift:7); but telemetry only activates when AppCenterCrash() is constructed in
App.swift and calls AppCenter.start(...) at line 20. Suppress the body of init() (signature line 8;
super.init() line 9; AppCenter.start line 20) rather than editing the hot App.swift call site or the
principal class:
Same warnings-as-error trap (the registration +
AppCenter.startlines 11-20 follow). Wrap them so the diff is a single safe hunk:override init() { super.init() // alt-tab-free [depaywall]: telemetry disabled — do not start AppCenter if false { UserDefaults.standard.register(defaults: ["NSApplicationCrashOnExceptions": true]) ... AppCenter.start(withAppSecret: AppCenterCrash.secret, services: [Crashes.self]) } }
Diff: 2 lines (if false { + }). App.appCenterDelegate = AppCenterCrash() still runs but the
object does nothing. (Upstream already sets AppCenter.networkRequestsAllowed = false at line 14, so it
only networks at crash-confirmation time anyway; this hard-stops even that.) The class is AppCenterCrash
(not AppCenterCrashes). Alternative — flipping Info.plist NSPrincipalClass (key:36/value:37) — is
NOT equivalent and is best avoided. The principal class is AppCenterApplication (verified —
Info.plist:37 value is AppCenterApplication, a vendored NSApplication subclass), and the app's own
App class subclasses it (App.swift:7 — class App: AppCenterApplication); Cocoa resolves the running
NSApp to the App subclass. Swapping NSPrincipalClass to NSApplication would unload
AppCenterApplication, breaking the App subclass chain and its bridging-header coupling — not just
AppCenter. The init-suppress above keeps NSPrincipalClass = AppCenterApplication and the framework
loaded, only skipping AppCenter.start() — strictly safer. Prefer it; do not swap NSPrincipalClass.
Edit 4 (nag scheduler): NOT NEEDED
Once state == .pro, ProTransitionManager.onLicenseStateChanged takes the .pro branch (cancels the
scheduler, emits dismissAllProWindows); onSwitcherShown's Day-X ladder only arms in trial/expired
states; and the Day-1 first-launch welcome in App.swift:~206-207
(if case .pro = LicenseManager.shared.state { return false }) suppresses itself. The nag scheduler goes
silent purely from Edit 1. Leaving it unpatched keeps that cold file at 0 fork-edits.
Edit 5 (upsell UI): leave it, do NOT edit
Hiding the Menubar "Get Pro"/"My Account" items, the Settings Upgrade tab, and the Pro badges would
require edits to Menubar.swift (warm 3/2), SettingsWindow.swift (warm 14/2), and Preferences.swift
(warm 156/2). With state == .pro the badge dot goes dark, the upgrade button/refresh paths report
unlocked, and ghost/lock styling doesn't render. The residual surface is purely cosmetic — a "Get Pro"
item may remain present-but-inert (clicking reaches an already-Pro state). Cosmetics are not worth
touching three warm files on every merge cycle. If the owner later insists on hiding it, do it as a
separate, clearly-marked optional patch the maintainer can drop when it conflicts — keep it out of the
core de-paywall diff.
Shipped-build surface — confirm what actually ships (owner sign-off)
Two surfaces must be consciously verified/accepted before first release (neither is per-merge):
- Release configuration strips DEBUG.
run_tests.shandbuild_app.shbuild-configuration Release, so#if DEBUGcode does not ship — includingQAMenu's manual mock-Pro / nag pokes andmockProUser()(#if DEBUG). Confirm the release pipeline never builds Debug, so those test hooks cannot reach end users. - Residual inert-but-visible Pro surface. With
state == .pro, badges go dark and upgrade paths report unlocked, but a present-but-inert "Get Pro" menubar item (and similar) may remain visible. The owner must consciously accept this cosmetic residue OR fold in the optional Edit-5 UI-hide patch. (Note:_test-support/Mocks.swift:133hardcodes the upstream bundle id — this is test-only, out of scope for the shipped build; called out so a reader cross-checkingAUDIT.md§5 isn't left wondering.)
Patch summary
| # | File | Cold/Hot | Edit | Lines |
|---|---|---|---|---|
| 1 | src/pro/license/LicenseManager.swift (computeState, body at 177) |
cold 1/0 | wrap body in if false { … }, then return .pro |
~3 |
| 2 | src/pro/license/LicenseCookie.swift (syncLicenseCookie, body at 6) |
cold 1/0 | wrap body in if false { … } |
2 |
| 3 | src/vendors/AppCenterCrashes.swift (init, body after super.init() at 9) |
cold 1/0 | wrap body (lines 11-20) in if false { … } |
2 |
Total: 3 files, ~7 inserted lines — all in COLD files with 0 commits since v11.0.0. Zero edits to any
hot file (App.swift, project.pbxproj), zero edits to warm files (Menubar.swift,
SettingsWindow.swift, Preferences.swift, ci_cd.yml); src/pro/ stays physically intact so
project.pbxproj is untouched by the de-paywall patch. The if false { … } wrap (not a bare
return above live code) is mandatory under SWIFT_TREAT_WARNINGS_AS_ERRORS = YES (base.xcconfig:7).
Prefix all three commits depaywall: so they're trivially locatable
(git log --oneline --grep='^depaywall:').
3. Identity & branding overlay (merge-stable)
Design principle: the fork's identity diff must live in files upstream never edits so merges
stay clean. The repo already provides the seam — config/local.xcconfig is #include?'d last in
both config/release.xcconfig:21 and config/debug.xcconfig:13, is already pbxproj-referenced, is
already .gitignore'd (.gitignore:20) and untracked, and CI already regenerates it. Because xcconfig
"last assignment wins," anything set there overrides base.xcconfig without touching base.xcconfig.
Everything else is achieved by overwriting the content of a file whose pbxproj reference path is
unchanged, or by the DOMAIN cascade. Net pbxproj edits for the whole overlay: zero.
3.1 Bundle id + PRODUCT_NAME + DOMAIN + signing — commit config/local.xcconfig
These live in base.xcconfig:3 (PRODUCT_NAME), :4 (PRODUCT_BUNDLE_IDENTIFIER), :20-21
(DOMAIN/API_DOMAIN), and release.xcconfig:5 (CODE_SIGN_IDENTITY).
Recommendation: un-gitignore and commit config/local.xcconfig as the single source of identity
truth — do not edit base.xcconfig/release.xcconfig in place:
PRODUCT_NAME = <ForkName>
PRODUCT_BUNDLE_IDENTIFIER = <com.fork.app>
DOMAIN = <fork.example>
API_DOMAIN = <fork.example/api>
CODE_SIGN_IDENTITY = Developer ID Application: <Fork Org> (TEAMID)
APPCENTER_SECRET = // empty — telemetry off
CI caveat (verified, lines 7-10): upstream's
scripts/replace_environment_variables_in_app.shdoescat > config/local.xcconfig(a truncating overwrite) writing BOTHCURRENT_PROJECT_VERSIONANDAPPCENTER_SECRET = $APPCENTER_SECRET— which would clobber the committed identity block on every CI run. A naïvecat >→cat >>(append) is NOT enough: the committed identity block above ALSO setsAPPCENTER_SECRET =, so an append yields twoAPPCENTER_SECRETlines. (xcconfig is last-wins, so the CI-appended empty value would win — harmless only because telemetry is off, which makes the "append won't collide" framing wrong, not safe.) Fix robustly — do one of:
- (preferred) Make the script write ONLY
CURRENT_PROJECT_VERSIONand append it — drop itsAPPCENTER_SECRETline entirely (telemetry is off, so the secret is unused).CURRENT_PROJECT_VERSIONis not in the committed block, so a single appended line is collision-free. OR- Write
CURRENT_PROJECT_VERSIONinto a separateversion.xcconfigthatlocal.xcconfig#includes, leaving the committed identity block untouched.(The fork rewrites CI anyway — §4.)
The cascade does most of the rest: Info.plist already pulls $(PRODUCT_BUNDLE_IDENTIFIER),
$(PRODUCT_NAME), $(DOMAIN), $(API_DOMAIN) as build variables (:7,:9,:76,:78), and
Endpoints.swift:4-5 reads Domain/ApiDomain from the bundle at runtime. The local.xcconfig
lines rebrand the bundle id, app name, URL scheme, license/website/feedback/appcast hosts, AND the
keychain/UserDefaults suite names (LicenseManager.swift:4-5 derive from App.bundleIdentifier) in one
shot — with zero edits to Info.plist, Endpoints.swift, or base.xcconfig. Note the keychain-suite
consequence: pick the fork's bundle id once and freeze it, or every user's stored state orphans on the
next id change.
OWNER DECISION (one coherent call, not two scattered caveats) — bundle id + the
remembered*data-loss scenario. §3.1 says "freeze the bundle id once"; §5 says "fresh install, no migration." These are two facets of the SAME decision — resolve it explicitly:
- (a) Mint a FRESH bundle id (recommended; simplest). The fork is a separate app with a clean
UserDefaults/keychain domain. Consequence to accept and disclose: existing AltTab users' prefs are NOT inherited — it is a fresh install, not an in-place upgrade. No migration code. This is the default this plan assumes.- (b) Reuse an existing bundle id (only if deliberately upgrading installed AltTab users in place). Then a one-time migration is REQUIRED, because the leave-
read()-intact de-paywall strategy (§2) never restores Pro selections that the paywalled build downgraded: users who passed trial expiry have their real Pro index parked inproTransition.remembered*keys (in the.licenseUserDefaults suite), andread()returning the stored (downgraded) value silently keeps the free equivalent. Ship a one-shot migration that, before first read, copies eachproTransition.remembered<Key>index back to its base key and clears theproTransition.*keys. (This is the same migrationAUDIT.md§6/Risk flags.)Pick (a) or (b) at bootstrap; do not leave it implicit. Either way the choice must be frozen — a later bundle-id change orphans state regardless.
local.xcconfigalone is NOT enough —ci_cd.ymlhardcodesAPP_NAME(verified):ci_cd.yml:19setsAPP_NAME: AltTab, andbuild_app.shchecks$APP_NAME.app/Contents/MacOS/$APP_NAMEwhilepackage_and_notarize_release.sh/scripts/update_appcast.sh:8zip/reference$APP_NAME-$version.zip. The built bundle name comes fromPRODUCT_NAME(xcconfig). So settingPRODUCT_NAME=<ForkName>but leavingAPP_NAME=AltTabmakesbuild_app.sh's existence check fail and the appcast/zip names mismatch the artifact.ci_cd.ymlis fork-owned (§3.5) — set itsAPP_NAMEenv equal toPRODUCT_NAME.PRODUCT_NAMEalso does not touch the hardcoded"AltTab"UI strings — see the §3.2 string sweep.
Rejected — Option B (new
config/fork.xcconfig+ an#includeappended tobase.xcconfig): that appended line is the only edit inside a file upstream does touch (20 commits), carrying a small recurring conflict risk on the tail ofbase.xcconfig. Option A has strictly zero edits to upstream files.
3.2 App name / icon (trademark)
- Login Items origin string (re-sign resolves it — verify it).
src/macos/LoginItem.swift:16setsAssociatedBundleIdentifiers = App.bundleIdentifierprecisely so System Settings → General → Login Items shows the app name rather than the signer's Developer ID display name ("Louis Pontoise" today, per the comment atLoginItem.swift:14-15). With the fork's own bundle id (§3.1) and the mandated fork re-sign (§3.3/§4.6), this resolves to the fork's identity automatically — but it is a visible origin/trademark surface, so add a bootstrap verification: after installing the fork build, open System Settings → Login Items and confirm it shows the fork's name/Developer ID, not "Louis Pontoise". (No code edit — this is a consequence of the bundle-id + re-sign, called out so it is checked, not assumed.) - Icon: ships as a plain file
resources/icons/app/app.icns(no asset catalog; onlyInfo.plist:18 CFBundleIconFile = app.icnsdrives it), referenced in pbxproj by relativepath. Overwrite the bytes ofapp.icnsand regenerateapp.iconset/*in place — the pbxproj path is unchanged, so zero pbxproj edits; git sees a binary blob change and any upstream icon change is a clean "both modified" the fork always resolves in its own favor. PRODUCT_NAME— handled by §3.1, but it does NOT touch hardcoded brand strings.PRODUCT_NAMEonly renames the bundle/App.name. Verified counts (the plan elsewhere over-counts — correct them):grep -rn 'AltTab' src/ --include='*.swift'returns 63 TOTAL hits, but ~25 of those are comments / translator-notecomment:strings that do not render; the actually-rendered surface is ~21NSLocalizedStringliterals containing "AltTab" plus the l10n template. Inresources/l10n/Localizable.stringsthere are 20AltTablines, of which 2 are/* … */comments → 18 translatable entries (replicated per*.lproj). A build withPRODUCT_NAME=<ForkName>still shows "AltTab" throughout the UI — exactly the trademark confusion §5 claims to avoid. Required one-time string sweep, classifying each hit (recurring but small merge surface on l10n + a few warm files thereafter):- (i) User-visible non-
src/prosrc/*.swiftliterals to route throughApp.name(verified examples):Menubar.swift:43(About %@— already usesApp.name),:52,:443;SettingsWindow.swift:304;AboutTab.swift:153("You have used AltTab:");AppearanceTab.swift:157;AppCenterCrashes.swift:62;PermissionsWindow.swift:36,:50;MoveToApplicationsFolder.swift:184;CustomRecorderControl.swift:110;FeedbackWindow.swift:100("Help improve AltTab"),:239("A new version of AltTab is available"). Where a%@/App.nameform already exists, prefer it so future renames are free (App.nameisApp.swift:13). - (ii)
src/pro/*literals that STILL render if any inert Pro UI is reachable (verified): e.g.ProPromptWindow.swift:12/UpgradeTab.swift:116("AltTab Pro"),Day1WelcomeLetterWindow.swift:25-26,Day15ProactiveWindow.swift:23/Day15FullUpgradeWindow.swift:24,Day21ReminderPopover.swift:14,ProTransitionManager.swift:35,ProConversionCopy.swift:32,:39. DECISION REQUIRED: §2/§5 tell the executor never to editsrc/pro— so either (a) sweep these despite that rule (a deliberate, documented exception, accepting thesrc/promerge surface), or (b) prove the inert-Pro UI is genuinely unreachable oncestate == .pro(the upsell windows are nag-scheduled, which never arms — but verify no other entry point renders them). - (iii) NON-display literals that MUST NOT be renamed:
PreferencesMigrations.swift:363uses"Screen showing AltTab"as an OLD STORED preference-value KEY in amigratePreferenceValuemap — renaming it breaks migration of existing users'screensToShowpref. (NoteMacroPreferences.swift:187.showingAltTab → NSLocalizedString("Screen showing AltTab")is a display string and may be reworded, but the migration KEY atPreferencesMigrations.swift:363must stay literal.) Also leaveApp.swiftlog strings and any non-UI literals alone. resources/l10n/Localizable.strings(and every*.lprojcopy): the 18 translatableAltTabentries (2 of the 20 lines are comments). Note line 605"Screen showing AltTab"mirrors the MacroPreferences display string, NOT the migration key — both happen to share the literal.- Durable CI gate (recommended): add a CI grep that fails if user-visible
"AltTab"reappears in the sweptNSLocalizedStringliterals, so the sweep doesn't silently regress on a future merge. - CI gate consequence: editing
src/*.swiftliterals and the.stringsfiles re-triggersscripts/ensure_generated_files_are_up_to_date.sh(regenerates l10n viagenstrings; §4). Runscripts/l10n/extract_l10n_strings.shand commit the regeneratedresources/l10n/*.stringsbefore pushing, or CI fails on the diff. The fork runner's Xcode/genstringsmust match upstream's to keep this gate green.
- (i) User-visible non-
- Attribution (GPL-3 §5/§7 + trademark): keep
LICENCE.mdbyte-for-byte (never edit). ReplaceREADME.mdwith the fork's README, which must state: " is a fork of lwouis/alt-tab-macos, © Louis Pontoise, licensed under GPL-3.0. This fork removes the Pro paywall; all features are free. Not affiliated with or endorsed by the original author." Add the same credit to the in-app About tab (AboutTab.swiftrenders aWebsitehyperlink toEndpoints.website, which auto-repoints via the cascade).
3.3 Sparkle (auto-update)
- EdDSA keypair: generate a fresh one. NOTE (verified):
vendor/Sparkle/bin/generate_keysdoes NOT exist —vendor/Sparkle/bin/ships onlysign_update, becausevendor/scripts/update_sparkle.shstep 10 (line 191) deliberately dropsgenerate_keys/generate_appcast/BinaryDelta. Obtaingenerate_keysby one of: (a) download the Sparkle 2.9.1 release tarball — the sameRELEASE_URL(https://github.com/sparkle-project/Sparkle/releases/download/2.9.1/Sparkle-2.9.1.tar.xz)update_sparkle.sh:14uses — and run its bundledbin/generate_keys; (b)brew install --cask sparkleand use itsgenerate_keys; or (c) temporarily un-dropgenerate_keysinupdate_sparkle.shstep 10.SUPublicEDKeyis a literal<string>value (not a$(VAR)): the<key>is atInfo.plist:61, the value at:62. Write the public key to the value onInfo.plist:62(not the<key>on :61) — cold, merge-safe. Keep the private key in CI secrets (SPARKLE_ED_PRIVATE_KEY, consumed byscripts/update_appcast.sh). - Re-sign the bundled Sparkle helpers (one-time, MANDATORY for notarization): the embedded
vendor/Sparkle/Helpers/Updater.appandvendor/Sparkle/Helpers/Autoupdateare currently signedDeveloper ID Application: Louis Pontoise (QXD7GW8FHY)/TeamIdentifier=QXD7GW8FHY(verified withcodesign -dvv). Apple notarization/Gatekeeper require every nested Mach-O to be Developer-ID-signed under a team consistent with the host app; a TeamIdentifier mismatch between the fork-signed.appand these lwouis-signed helpers causes notarization rejection or a Gatekeeper launch failure for end users (thedisable-library-validationentitlement can mask it locally while notarization still rejects). Action: before first release, re-signUpdater.app+Autoupdateunder the FORK's Developer ID (e.g. runvendor/scripts/update_sparkle.shwith the fork cert in the keychain / set the helper signing identity), re-commit them, and verifycodesign -dvv vendor/Sparkle/Helpers/Updater.appshows the fork's TeamIdentifier. Re-do this on every Sparkle version bump. (See §4.0 — "reuse scripts verbatim" must NOT be read as "signing is untouched.")- Where the helpers actually get embedded —
scripts/copy_sparkle_helpers.sh(verified): the build phase "Copy Sparkle Helpers" runs this script, which copiesvendor/Sparkle/Helpers/{Updater.app, Autoupdate}intoSparkle.framework/Versions/A/inside the built.appand re-sealsSparkle.frameworkwith the build'sCODE_SIGN_IDENTITY(solocal.xcconfig's fork identity flows through automatically — no edit to this script). Critically, the script's own comment confirms it does NOT re-sign the nestedUpdater.app/Autoupdate— those keep their vendor-time signature. So the bootstrap re-sign ofvendor/Sparkle/Helpers/*is exactly what determines the embedded helpers' team: if you skip it, this script embeds lwouis-TeamID helpers under a fork-TeamID framework — the precise notarization/GatekeeperTeamIdentifiermismatch warned about above, with the copy site beingcopy_sparkle_helpers.sh. - Post-build verification (REQUIRED): after
build_app.sh, runcodesign -dvvon theSparkle.framework/Versions/A/Updater.appinside the built.app(not just the vendored source copy) and confirm the fork'sTeamIdentifier. Promote the parenthetical CI "TeamIdentifier-drift" check (§6.1 / below) to a REQUIRED gate that fails the build if any nested Mach-O'sTeamIdentifier≠APPLE_TEAM_ID.
- Where the helpers actually get embedded —
- Feed URL:
Endpoints.appcastUrl = "\(website)/appcast.xml"(Endpoints.swift:7, wherewebsite = "https://\(domain)") — repoints via theDOMAINcascade automatically; no code edit. Precondition: the fork must own a web host that actually serveshttps://<DOMAIN>/appcast.xml(or accept agithub.iovalue forDOMAINand CNAME a gh-pages branch). The Sparkle feed URL is fixed by theDOMAINcascade, so a bare release-asset URL won't match unless<DOMAIN>serves/appcast.xml. appcast.xml— the conflict trap (verified tracked):appcast.xmlis committed and contains upstream's EdDSA-signed release history. If the fork keeps signing into the same file, every upstream release and every fork release edits it → guaranteed conflicts, plus the fork would serve entries signed with lwouis's key its public key can't verify. This is one coupled bootstrap change with three parts that must all land together — removing the file alone breaks the build:git rm appcast.xml+ add/appcast.xmlto.gitignore, and publish the feed out-of-tree (gh-pages branch or a release asset).- Rewrite
scripts/update_appcast.sh. As shipped it edits an in-treeappcast.xml:set -exu(line 3) makes a missing file a HARD failure, and line 26 (sed -i '' -e "/<\/language>/r ITEM.txt" appcast.xml) appends into the tracked file. The rewrite must (a) check out / fetch the gh-pages copy of the feed, appendITEM.txtthere, and push it to the publish location; (b) fix the enclosure URL on line 18 (github.com/lwouis/alt-tab-macos/releases/download/...→ the fork's releases — also flagged §4.0/§4.3 as the single most safety-critical edit); and (c) fix thereleaseNotesLinkon line 16 (https://alt-tab.app/changelog-bare→ a fork URL or drop it). - Remove
'appcast.xml'from the@semantic-release/gitassetsarray inrelease.config.js(lines 20-26 — currently['changelog.md', 'appcast.xml', 'README.md']). Otherwise semantic-release tries to commit the now-untracked file back every release. NOTE:release.config.jsis upstream-tracked (so this is a small recurring conflict surface — flag it as "keep ours").
3.4 Endpoints + Secrets + telemetry
Endpoints.swift is cold and entirely derived from Domain/ApiDomain. Once state == .pro, the
checkout/account UI paths and the periodic revalidation are never invoked — but the activate POST is
not state-gated (it's reachable any time via <bundleid>://activate, see §2) and it exfiltrates the
machine hardware UUID (RemoteLicenseClient.swift:18 → MachineFingerprint.swift:13, kIOPlatformUUIDKey),
so handling the activate surface is required, not optional. The activate fingerprint POST is closed
by neutralizing handleCustomUrl (§2 default), NOT by where API_DOMAIN points — decoupling the two so
that the feedback decision below cannot re-arm fingerprint exfiltration. API_DOMAIN is still shared with
the live feedback POST, and the choice of its value is purely a feedback decision (the routability tension is
about feedback working vs. not — the activate surface is handled separately). Per-URL disposition:
URL (Endpoints.swift) |
Consumer | Disposition |
|---|---|---|
licenseApiBaseUrl :11 |
RemoteLicenseClient.activate/validate/deactivate |
validate/deactivate are dead (need a keychain key). activate is NOT dead — the <bundleid>://activate handler (App.swift:474-481) is scheme-gated, not state-gated, and POSTs the machine hardware UUID (RemoteLicenseClient.swift:18→MachineFingerprint.swift:13) here. REQUIRED (default): neutralize handleCustomUrl (drop the activate host) so the POST is dead regardless of API_DOMAIN — do NOT rely on a non-routable API_DOMAIN, which silently re-arms the fingerprint POST to the fork's own backend if feedback option (a) makes API_DOMAIN routable. |
checkoutUrl :9, accountUrl :10 |
nag scheduler / Upgrade UI | Dead (never armed / UI quiet). |
website :6 |
About tab, cookie | Repoints via DOMAIN. Automatic. |
supportUrl :8 |
App.swift (supportProject / Menubar "Support" item) |
Derives from website (= https://\(DOMAIN)/support), so it repoints via the DOMAIN cascade to https://<DOMAIN>/support. Owner decision (state it, don't leave implicit): either (a) serve a real /support page on <DOMAIN>, or (b) the in-app "Support" affordance 404s. Note this is a distinct surface from .github/FUNDING.yml (the repo Sponsor button, §3.5) — both must be consciously pointed at the fork (or removed), or one silently keeps pointing at the upstream author. |
feedbackUrl :12 |
FeedbackWindow |
The one runtime POST that still fires (user-initiated feedback). FeedbackWindow.prepareRequest() (line 445) POSTs JSON {title, body, kind, debugProfile} — DebugProfile.make() carries versions/settings/hardware — to a backend that owns the GitHub-issue rendering server-side. A non-routable API_DOMAIN makes every submission fail (showSubmitFailureAlert, line 409): this DISABLES a user-visible feature, it is not dead code. See the §3.4 decision below — pick a real option, don't default to non-routable here. |
appcastUrl :7 |
SparkleDelegate.feedURLString |
Recurring GET on every scheduled check (Info.plist SUEnableAutomaticChecks, SUScheduledCheckInterval=604800), independent of license state. SparkleDelegate appends version/macos/arch/lang query params (SparkleDelegate.swift:33-36). Repoints via DOMAIN (§3.3). If <DOMAIN> is not fork-owned and serving appcast.xml, update checks 404 or leak a coarse system profile to a third party. |
§3.4 decision — the activate fingerprint POST is closed independently of API_DOMAIN:
licenseApiBaseUrl/ the<bundleid>://activatePOST — closed by the §2handleCustomUrlneutralization (drop theactivatehost), which kills the hardware-UUID POST regardless of whereAPI_DOMAINpoints. Do NOT rely on a non-routableAPI_DOMAINfor this: a non-routable host only dead-ends the POST as a side-effect, and that protection silently re-arms the moment feedback option (a) below makesAPI_DOMAINroutable (the craftedactivatelink then POSTs the fingerprint to the fork's own backend). A routableAPI_DOMAINalone does NOT makeactivatesafe — the handler edit does.feedbackUrl/ FeedbackWindow —API_DOMAINdrives this; choose its value purely on the feedback decision below. Becauseactivateis already neutralized at the handler, you may now makeAPI_DOMAINroutable (option a) without re-opening any fingerprint surface. A non-routable host still breaks the feedback feature (every submit fails) — a user-visible regression, not dead-code cleanup. Pick one honestly and document it:- (a) Stand up a fork feedback backend at
API_DOMAINthat accepts the{title, body, kind, debugProfile}POST and files/forwards issues. Keeps the feature; most work. - (b) Rewrite
FeedbackWindow.submit/prepareRequestto build a prefilledhttps://github.com/<fork>/issues/new?title=…&body=…URL client-side andNSWorkspace.shared.openit (dropping the JSON POST +debugProfileattach). Scope honestly: this is a REAL edit to a warm file — the current code constructs no client-side GitHub URL and the backend owns issue rendering, so this is a rewrite ofFeedbackWindow.swift, not a "small/localized" one-liner. - (c) Hide the feedback entry point entirely.
Whichever you choose, the
activate-POST fingerprint surface is already closed by the §2handleCustomUrlneutralization, independent ofAPI_DOMAIN— the two decisions are decoupled.
- (a) Stand up a fork feedback backend at
Secrets.swift reads only AppCenterSecret from the bundle. Telemetry kill: set
APPCENTER_SECRET = (empty) in local.xcconfig (AppCenter init no-ops on an empty secret), reinforced by
Edit 3 (§2).
Owner acknowledgment — "no telemetry" means "telemetry disabled, SDK still linked," not "AppCenter removed." This plan deliberately keeps
src/pro/src/vendorsphysically present (the merge-surface argument, §0), so the AppCenter SDK — which is End-of-Life (AUDIT.md§1/§2/§6) — remains linked into the binary but inert (empty secret + Edit-3initsuppression + upstream'snetworkRequestsAllowed = false). A privacy-conscious user reading "no telemetry" should understand the SDK is not excised; it is prevented from starting. If the owner wants the EoL SDK fully out of the binary, that is the full-excision path (PLAN-republish-free.md/AUDIT.md§5), which this plan rejects for the tracked-fork goal. State this so "no telemetry" is not over-claimed. Do NOT also swapNSPrincipalClass(Info.plistkey:36/value:37). The principal class isAppCenterApplication(verified —Info.plist:37value, a vendoredNSApplicationsubclass), and the app's ownAppclass subclasses it (App.swift:7); swapping toNSApplicationwould unloadAppCenterApplicationand break theAppsubclass chain + bridging-header coupling, not just AppCenter. Edit 3'sinit-suppress already achieves telemetry-off without that risk; they are not equivalent. Drop the fork's CI use ofupload_symbols_to_appcenter.shandupdate_website.sh.
3.5 Identity overlay file set
Fork-owned (upstream never touches → zero conflict):
config/local.xcconfig— un-gitignore + commit (PRODUCT_NAME, bundle id,DOMAIN,API_DOMAIN,CODE_SIGN_IDENTITY, emptyAPPCENTER_SECRET).README.md— full replacement with GPL-3 + fork attribution.- Fork-owned CI workflow + rewritten
scripts/update_appcast.sh/replace_environment_variables_in_app.sh.
Upstream-tracked file that funnels money/identity to lwouis — repoint or remove (AUDIT.md §5):
.github/FUNDING.yml(verified:github: lwouis,patreon: alt_tab_macos,ko_fi: alt_tab, and a lwouis PayPalhosted_button_id). Left as-is, GitHub renders a "Sponsor" button on the fork's repo that routes every donation to the author the fork just de-paywalled — an identity/phone-home surface and a recurring conflict file (every upstream edit to it collides). Bootstrap action: eithergit rm .github/FUNDING.yml, or rewrite it to the fork's own sponsorship (or leave it empty). Add it to the §6.2 "keep ours" recurring-conflict set alongsideci_cd.yml/update_appcast.sh/release.config.js.
Content-overwrite by stable path (zero pbxproj edits, "fork always wins" on conflict):
resources/icons/app/app.icns+app.iconset/*.
Tracked-file removal: git rm appcast.xml + /appcast.xml in .gitignore; CI publishes the feed out-of-tree.
Minimal in-place edits to COLD dormant lines (merge-safe):
Info.plist:62(SUPublicEDKeyvalue; the<key>is on :61).src/App.swift:16—static let repository = "https://github.com/lwouis/alt-tab-macos"→ the fork's GitHub URL. This is the in-app GPL §6 corresponding-source pointer: it is consumed byAboutTab.swift:19(the in-app "Source code" link) andFeedbackWindow.swift:136(App.repository + "/issues"). Left as-is, the fork's "Source code" link misdirects users to lwouis's paywalled tree rather than the fork's source — a GPL §6 compliance gap, not just cosmetics. This is the ONE cold-linesrc/App.swiftdivergence (the file is hot — 5 commits since v11.0.0 — but line 16 has not moved); it is a one-time bootstrap edit, not per-merge. Optionally relocaterepositoryto a fork-owned file to zero out even that. Decide whetherFeedbackWindow's/issuesshould follow the same repoint.- Optionally
FeedbackWindow.swiftfeedbackUrl(only if no fork feedback backend — see §3.4 table).
Do NOT edit: Info.plist:36/37 NSPrincipalClass (value = AppCenterApplication, which App
subclasses — see §3.4; swapping it is not equivalent to Edit 3, and breaks the App subclass chain).
Untouched — DO NOT edit (cascade does the work): config/base.xcconfig, config/release.xcconfig,
src/api/Endpoints.swift, src/api/Secrets.swift, project.pbxproj, all upsell-UI files.
Attribution-preserve set — the surfaces where the binding GPL §5/§7 attribution actually lives; keep
verbatim, NEVER swept or deleted (PLAN-republish-free §A item 2). Stripping or displacing any of these
would terminate the GPL license under §8:
CORRECTION (verified — AltTab uses NO per-file copyright headers). An earlier draft told the executor to "preserve all source-file copyright headers verbatim (e.g. the upstream header in
src/App.swift)" and attached the §8-termination warning to that. There is no such header.grep -rln -iE 'copyright|SPDX|Licensed under|Pontoise|GPL' src/ --include='*.swift'matches only 2 of 205 swift files, and neither is a header:src/App.swift's hits are the runtime accessorsstatic let licence = Bundle.main.object(forInfoDictionaryKey: "NSHumanReadableCopyright")(line 15) andstatic let repository(line 16) —App.swiftbegins withimport Cocoa, no copyright block — andsrc/macos/LoginItem.swift's hit is an inline code comment. So a per-merge "confirm source copyright headers survived" check verifies nothing while creating false confidence. The binding attribution surface is the four items below, not per-file headers. Rule: preserve whatever headers exist (currently none of substance), and never ADD a fork-only copyright that displaces upstream attribution (e.g. do not prepend a "© " header to source files — that would be the actual §8-termination risk).
- (a)
LICENCE.md(GPL-3 text — required for compliance). Keep byte-for-byte; never edit. - (b)
Info.plist:21NSHumanReadableCopyright = "GPL-3.0 licence"(verified —<key>at :20, value at :21), surfaced at runtime viaApp.licence(App.swift:15) and rendered byAboutTab.swift:17. This is the in-app §5(d) "Appropriate Legal Notices" string — do NOT remove or overwrite it with a fork-only notice; it must keep stating GPL-3. - (c) the README / About-tab fork credit — " is a fork of
lwouis/alt-tab-macos, © Louis Pontoise, GPL-3.0" (§3.2). This is the prominent §5(a)/(b) attribution; the §3.2 brand sweep must never delete it (nominative fair use, REQUIRED by §5 — see the sweep boundary below). - (d)
docs/contributors.md(lists upstream developers + translators) anddocs/acknowledgments.md(third-party library credits) — both exist in-tree today and the deferred §A baseline explicitly says they MUST be retained.docs/acknowledgments.mdis also rendered in-app (AcknowledgmentsTab.swiftloadsacknowledgments.mdfrom the bundle, surfaced viaMacroPreferences's Acknowledgments tab), so deleting or breaking it also breaks a visible UI panel. - Trademark-sweep boundary (§3.2): the §3.2 brand-string sweep MUST touch ONLY the product-name brand
strings ("AltTab" as a rendered product name). It MUST NOT over-sweep author-name attribution — e.g.
"© Louis Pontoise", the
lwouis/alt-tab-macosrepo reference in the README/About credit, or the contributor names. "fork of AltTab" / "based on AltTab by Louis Pontoise" in the attribution is lawful nominative fair use and is REQUIRED by §5; do not let an over-eagergrep -r 'AltTab'sweep delete it.
Net recurring identity conflict surface per merge: the only in-tree upstream files edited are
Info.plist (~2-3 dormant lines, 0 commits since v11.0.0) and optionally one FeedbackWindow.swift line.
Identity is as merge-stable as the de-paywall patch.
3.6 Distribution & first-run UX (how a NEW user gets and trusts the first build)
§3.3 covers auto-update in depth, but the plan otherwise never says how a brand-new user obtains the first build or gets past Gatekeeper the first time — auto-update only takes over after install. Decide these at bootstrap (they are owner decisions, not per-merge work):
- (a) Primary download channel. Pick one and link it from the README and
<DOMAIN>:- GitHub Releases (simplest; the pipeline already publishes a notarized
.zipthere viasoftprops/action-gh-release— §4.0). Linkhttps://github.com/<fork>/releases/latestfrom the README. This is the recommended default. - Homebrew Cask (better discovery). Cost to absorb in the loop: a Cask is a recurring
upstream-tracking task of its own — every fork release must bump the cask's
version/sha256(auto-PR via a bumper bot, or manual). Only adopt if the owner accepts that recurring cost; otherwise defer it. - The repointed
<DOMAIN>site as a download landing page (it must exist anyway to serve/appcast.xml— §3.3 — so a download link there is cheap).
- GitHub Releases (simplest; the pipeline already publishes a notarized
- (b) Tie the download URL to the same
<DOMAIN>/appcast host §3.3 already requires so the first-install link and the auto-update feed are one consistent origin (avoids a second host to keep alive). - (c) Gatekeeper first-run story. The build is notarized + stapled (§3.3/§4.0) but signed by an
unknown (non-App-Store, fork) developer, so on first launch macOS still shows a "downloaded from the
internet" prompt. Notarization + stapling is necessary (without it Gatekeeper hard-blocks on recent
macOS); document in the README the expected first-run flow (right-click → Open, or System Settings →
Privacy & Security → Open Anyway). Do NOT promise an App-Store-grade silent first launch — that requires
identifiers the fork doesn't have. The post-build
notarytool+ staple verification (§3.3 / §6.1) is what guarantees this prompt is the only friction, not an outright block.
3.6.1 Steady-state fork-to-fork auto-update (the upgrade the perpetual-republish promise depends on)
§3.6(a)-(c) cover a brand-new install; §4.6 covers the Sparkle private-key break-glass. But the "continuously republish" promise lives or dies on a different path: a user already running fork build N must actually receive fork build N+1 via Sparkle. Nothing in the plan validates this end-to-end, and a silently-broken Sparkle upgrade strands existing fork users on a stale build with no signal (the appcast GET succeeds, but the enclosure/signature/version handling fails quietly). This is the upgrade analogue of the §4.4 feature-unlock check and must be proven, not asserted. The four things that must hold, all of which the fork now controls (so all of which can break on a fork-side mistake):
- Enclosure URL resolves to the FORK's release —
update_appcast.sh:18enclosure points atgithub.com/<fork>/releases/download/...(the §4.0 "single most safety-critical edit"); a leftover lwouis URL silently pulls the upstream paywalled binary as the "update." - EdDSA signature verifies against the EMBEDDED fork public key — the running build N embeds the fork's
SUPublicEDKey(Info.plist:62); N+1's appcast entry must be signed with the matching fork private key (SPARKLE_ED_PRIVATE_KEY). A key mismatch makes Sparkle reject the update silently. SUVersion/version ordering is monotonic — the fork'sv100.0.0offset (§4.3) must keepsparkle:versionstrictly increasing across fork releases so Sparkle offers N+1 over N (and an upstreamv11.x/v12.xnumeric never out-sorts a fork version).- Gatekeeper is the only friction — the in-place upgrade installs with the notarized/stapled build, the same single prompt as first run, nothing harder.
This is validated by the §6.1 bootstrap acceptance step and re-checked per release (§6.2 step 10) below.
4. Maintenance model & auto-republish pipeline
4.0 What the fork inherits vs. owns
Upstream's pipeline is one workflow, .github/workflows/ci_cd.yml, triggered on: push: branches: [master]
ONLY (verified — no pull_request: trigger; it is the sole workflow, so the sync PR runs no CI until
the fork adds one — §4.2), running on macos-15, chaining the scripts/: print_env → npm ci →
commitlint → ensure_generated_files_are_up_to_date.sh → determine_next_version.sh
(semantic-release dry-run) → replace_environment_variables_in_app.sh (writes local.xcconfig) →
codesign/setup_ci_master.sh → run_tests.sh (xcodebuild test -scheme Test) → build_app.sh (the
"Copy Sparkle Helpers" build phase runs scripts/copy_sparkle_helpers.sh, which embeds the
vendor/Sparkle/Helpers/* binaries into Sparkle.framework and re-seals the framework — §3.3) →
package_and_notarize_release.sh (zip → notarytool → stapler) → upload_symbols_to_appcenter.sh →
update_appcast.sh (Sparkle sign_update) → update_readme_and_website.sh → npx semantic-release
(line 59) → extract_latest_changelog.sh (line 61 — sets the tag_name/body outputs for the release
step) → softprops/action-gh-release (line 62, body: …, files: …/*.zip) → update_website.sh (line 67).
Insert the §4.4 Guard B + Guard C greps as the FIRST steps of this push job — before run_tests.sh
(line 53), so they run before update_appcast.sh (57, Sparkle sign_update), npx semantic-release
(59, which pushes the vX.Y.Z tag to master), and softprops/action-gh-release (62). Placing them only
"before line 62" is too late: by line 62 the tag is already pushed and the binary already signed (§4.4
"Where the guards run"). Keep a second copy before line 62 as belt-and-suspenders. Dropping
update_readme_and_website.sh leaves extract_latest_changelog.sh in place (it still feeds the release body
— inject the §5 source link there).
Secrets consumed: GITHUB_TOKEN, APPCENTER_SECRET, APPCENTER_TOKEN, APPLE_ID, APPLE_PASSWORD,
APPLE_TEAM_ID, APPLE_P12_CERTIFICATE, SPARKLE_ED_PRIVATE_KEY, WEBSITE_DISPATCH_TOKEN.
Three lwouis hardcodes are the fork's hard divergence points (independent of the chokepoints):
scripts/update_appcast.sh:18— enclosure URLgithub.com/lwouis/alt-tab-macos/releases/download/.... Must point at the fork's releases or every Sparkle auto-update pulls upstream's signed, paywalled binary. Single most safety-critical edit.scripts/update_website.sh:5—gh api repos/lwouis/alt-tab-website/dispatches. Drop this step.scripts/update_readme_and_website.sh— pulls lwouis stars/downloads for README SVGs. Cosmetic; neuter or drop.
Verdict: adapt, don't reinvent. Reuse the build/sign/notarize/appcast scripts' structure, changing
those three URLs, the secret values, APP_NAME (§3.1), and the appcast publish target (§3.3). "Reuse
verbatim" does NOT mean signing is untouched: the embedded Sparkle helpers under
vendor/Sparkle/Helpers/ are signed with lwouis's Developer ID and MUST be re-signed under the fork's cert
before first release (§3.3) or notarization/Gatekeeper rejects the build. scripts/copy_sparkle_helpers.sh
is the build phase that embeds those helpers into Sparkle.framework and re-seals the framework with
CODE_SIGN_IDENTITY (so local.xcconfig's fork identity flows through — no edit to this script); but per
its own comment it does not re-sign the nested helpers, so the bootstrap re-sign of
vendor/Sparkle/Helpers/* is what determines the embedded helpers' TeamID (§3.3). Also note ci_cd.yml
ships with no pull_request: trigger (so the sync PR runs no CI — §4.2), without permissions:, and
with environment: production (§4.3) — all three need fork-side setup or the PR gate / release push /
publish silently fails (no pre-merge guard, 403, or stall).
4.1 Git model — merge (recommended)
Use merge, not rebase/patch-queue. The de-paywall change is a handful of edits to four cold files
with zero upstream commits since v11.0.0 — the base under the patch is dead-still, so a patch-queue's
clean-linear-history benefit buys nothing while its cost (re-applying format-patch atop a pinned ref
every cycle, re-resolving fuzz whenever any nearby line shifts) is pure overhead. Merge keeps the fork's
identity/CI commits as durable history that 3-way merge reconciles automatically, and surfaces a
chokepoint collision exactly once as a normal conflict in a known file — the loud failure we want.
Rejected alternative — "contribute an upstream build-flag seam" (a
#if FREE_BUILD/ paywall-off compile flag merged INTO upstream). On paper this would make the fork a zero-conflict config toggle. It is a non-starter: lwouis sells Pro, so a maintainer whose revenue depends on the paywall will not merge a first-class switch that turns it off — the PR would be declined, and pursuing it wastes effort and tips the fork's hand. The fork therefore cannot rely on upstream cooperation for the de-paywall seam; the thin inert patch on dormant lines (§2) is the right design precisely because it needs no upstream buy-in.
One-time setup:
git remote add upstream https://github.com/lwouis/alt-tab-macos.git
git fetch upstream --tags
Per-release sync (steady state):
git fetch upstream --tags
git switch -c sync/upstream-v11.4.0 master
git merge v11.4.0 # 3-way merge upstream's tag
# expected: clean, OR conflicts ONLY in identity/CI files we own
The four chokepoints are dormant upstream, so the depaywall: commits don't conflict on a normal sync.
The files that can conflict are the ones the fork intentionally diverged — Info.plist,
scripts/update_appcast.sh/update_website.sh, .github/workflows/ci_cd.yml — and those resolutions are
mechanical ("keep ours").
When upstream DOES touch a chokepoint (rare; zero occurrences so far): the merge halts with a
conflict in the chokepoint file. Confirm it's a chokepoint (git diff --name-only --diff-filter=U),
re-apply the de-paywall intent on top of upstream's new code, run the anti-relock test locally until
green (§4.4), then commit. Even a mis-resolution can't ship — the §4.4 test fails CI before any
signed build.
4.2 Trigger / cadence — scheduled cron opening a sync PR
A scheduled GitHub Actions job beats repository_dispatch/webhooks: the fork can't add a webhook to
upstream, polling tags on a cron needs no upstream cooperation, and a reviewable PR lets a human eyeball
the merge + the §4.4 test before it ships. Keep this separate from ci_cd.yml — new workflow
.github/workflows/upstream_sync.yml:
name: upstream-sync
on:
schedule: [{ cron: '0 7 * * *' }] # daily; cheap, idempotent
workflow_dispatch: {}
permissions: # GITHUB_TOKEN is read-only by default; PR + push need these
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-latest # pure git, no Xcode
env:
# CRITICAL — do NOT use ${{ github.token }} here. A branch push / PR authored with the
# default GITHUB_TOKEN does NOT trigger other workflows (GitHub's documented recursion
# prevention), so guard.yml and ci_cd.yml's pull_request checks would NEVER fire on the
# sync PR — the entire pre-merge gate would silently never run (see the blocking note below).
# Use a fork-owned credential that re-triggers workflows: a GitHub App installation token
# (preferred) or a classic/fine-grained PAT, stored as a repo/Environment secret.
GH_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }} # GitHub App installation token (preferred) or PAT
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Persist the fork-owned credential (NOT the default GITHUB_TOKEN) as the push
# credential, so the branch push below re-triggers guard.yml / ci_cd.yml's PR checks.
token: ${{ secrets.SYNC_BOT_TOKEN }}
- name: Add upstream + fetch tags
run: |
git remote add upstream https://github.com/lwouis/alt-tab-macos.git
git fetch upstream --tags
- name: Find newest upstream tag not yet merged
id: tag
run: |
# UPSTREAM-ONLY: `git tag -l 'v*'` would also match the FORK's OWN semantic-release tags
# (the fork cuts vX.Y.Z every sync — §4.3), so the "newest tag" could be a fork tag, not
# upstream's. List tags from the upstream remote explicitly instead.
latest="$(git ls-remote --tags --refs upstream 'v*' \
| awk -F/ '{print $NF}' | sort -V | tail -1)"
# GUARD 1 — already merged: bail if it's already an ancestor of master, else the cron
# re-opens the same PR every day until the next upstream tag ships.
# `origin/master` exists after actions/checkout@v6 with fetch-depth:0; if in doubt use
# `git rev-parse HEAD` (the checked-out master tip).
if git merge-base --is-ancestor "refs/tags/$latest" HEAD; then
echo "Already merged: $latest"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0
fi
# GUARD 2 — a prior sync is still OPEN (stalled/blocked): do NOT skip the stranded tag and
# leap to the newest one. An open sync/* PR means an earlier upstream tag is unmerged; merging
# the newest tag straight onto master would abandon the intermediate tag/PR and silently drop
# the commits in between. Bump/notify the existing PR instead, and let the human resolve it
# before the next tag is synced (resolve-in-order). See the §4.2 stalled-sync semantics below.
open_sync="$(gh pr list --state open --label sync --json headRefName,number,createdAt \
--jq 'sort_by(.createdAt) | .[0]')"
if [ -n "$open_sync" ] && [ "$open_sync" != "null" ]; then
echo "A prior sync PR is still open: $open_sync"
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "stalled=true" >> "$GITHUB_OUTPUT" # liveness alert keys off this (below)
exit 0
fi
echo "latest=$latest" >> "$GITHUB_OUTPUT"
- name: Merge onto a sync branch (real merge commit, conventional message)
if: steps.tag.outputs.skip != 'true'
id: merge
run: |
git config user.name "fork-sync-bot"
git config user.email "fork-sync-bot@users.noreply.github.com"
git switch -c "sync/${{ steps.tag.outputs.latest }}"
# NO --no-commit: let git author a real merge commit with a CONVENTIONAL message
# so commitlint/semantic-release stay happy (see §4.3). On conflict, commit the
# conflicted tree so the PR shows the markers for a human to resolve.
if git merge --no-ff -m "chore(sync): merge upstream ${{ steps.tag.outputs.latest }}" "${{ steps.tag.outputs.latest }}"; then
echo "conflicts=false" >> "$GITHUB_OUTPUT"
else
git add -A && git commit -m "chore(sync): merge upstream ${{ steps.tag.outputs.latest }} (CONFLICTS — resolve before merge)" --no-verify
echo "conflicts=true" >> "$GITHUB_OUTPUT"
fi
git push origin "HEAD:sync/${{ steps.tag.outputs.latest }}"
- name: Open PR
if: steps.tag.outputs.skip != 'true'
run: |
gh pr create \
--head "sync/${{ steps.tag.outputs.latest }}" \
--base master \
--title "sync: merge upstream ${{ steps.tag.outputs.latest }}" \
--body "Automated upstream sync. Verify green CI (incl. anti-relock test) before merge." \
--label "${{ steps.merge.outputs.conflicts == 'true' && 'sync,conflict' || 'sync' }}"
Why this shape (verified against ci_cd.yml + release.config.js):
- No
--no-commit. The originalgit merge --no-commit --no-ffthen handing a dirty tree tocreate-pull-requestwould let that action author ONE squashed, non-conventional commit, discarding upstream's commits and the merge commit.commitlintlints the whole pushed range (ci_cd.yml:48,--from "$GITHUB_EVENT_BEFORE" --to "$GITHUB_EVENT_AFTER") and@semantic-release/commit-analyzerneeds conventional commits, so that squashed/non-conforming commit would fail the release job or yield a wrong bump. Authoring a realchore(sync):merge commit and pushing it ourselves avoids this. - Upstream-only tag selection + the
merge-base --is-ancestorguard. Enumerate tags from the upstream remote (git ls-remote --tags upstream), NOTgit tag -l 'v*'— the latter also matches the fork's OWN release tags (the fork cutsvX.Y.Zevery sync, §4.3), so "newest tag" could be a fork tag. Without the ancestor guard the newest tag is selected unconditionally, so after mergingv11.4.0the cron re-createssync/v11.4.0and re-opens an identical PR every day untilv11.5.0ships — training the maintainer to ignore the review gate. - Explicit
permissions:+gh pr create. GitHub defaults the workflowGITHUB_TOKENto read-only; push and PR creation needcontents: write+pull-requests: write. Butpermissions:is necessary, not sufficient — see the blocking token note immediately below: the credential used to push the branch and open the PR must NOT be the defaultGITHUB_TOKEN, or no pre-merge check ever runs.
BLOCKING — the pre-merge gate never fires if the sync bot authors with the default
GITHUB_TOKEN. By GitHub's documented recursion-prevention rule, a branch push or PR created by a workflow using the ambientGITHUB_TOKENdoes NOT trigger other workflow runs (nopush/pull_requestevents fire from it). So ifupstream_sync.ymlpushessync/*and runsgh pr createwith${{ github.token }}, thenguard.ymlandci_cd.yml'spull_request:checks never run on the sync PR — and the §6.2 "verify the PR's CI is green" gate the reviewer relies on has ZERO checks to look at. The architecture then silently degrades to exactly the unsafe state §4.2 warns about: the only surviving guard is the release-time Guard B/C grep inci_cd.yml, which runs post-merge — but, wired as the FIRST push-job step (§4.4), it still aborts the job beforeupdate_appcast.sh/semantic-releasepush the tag or sign the binary, so a relock/conflict-laden tree is blocked rather than shipped (the failure mode is degraded — no PR review — but still fail-closed). Fix at the design level — the sync bot MUST author the push and the PR with a credential that re-triggers workflows:
- (preferred) A GitHub App installation token scoped to this repo (least-privilege, short-lived, auto-rotating) stored as
SYNC_BOT_TOKEN. Used as theactions/checkouttoken:(so the push re-triggers) and asGH_TOKENforgh pr create.- (acceptable) A fork-owned classic/fine-grained PAT with
contents+pull-requestswrite, stored asSYNC_BOT_TOKEN. Simpler but it expires and becomes a new silent SPOF (see §4.6), and if leaked it can push tomaster— prefer the App token.- (alternative) Self-author the push with the default token, then explicitly re-dispatch the guard checks via
workflow_dispatch/repository_dispatchcarrying the bot's own token. More moving parts than the App token; only choose it if a token cannot be provisioned.Acceptance test (do once at bootstrap): open a sync PR through the bot and confirm the
guard.ymlANDci_cd.ymlpull_request:checks actually APPEAR and run on it — not merely that a hand-pushed branch goes red. A bot PR with no checks attached means the token is wrong and the gate is disarmed.
Stalled / skipped-sync semantics (define this, don't leave it to chance). The merge-base --is-ancestor HEAD guard only suppresses re-opening an already-merged tag; it says nothing about an
open sync PR that a human hasn't merged yet. Two upstream tags can ship before the maintainer resolves the
first PR — and if the cron then merged the newest tag straight onto master, it would abandon the
stranded intermediate tag/PR and silently drop the commits between them. The loop MUST therefore pick one
defined behavior when an open sync/* PR already exists (this plan adopts (a)):
- (a) Resolve-in-order (default). While any
sync/*PR is open, the cron does NOT open a new one — it bails (Guard 2 above) and the liveness alert escalates on the stale-open trigger. The maintainer merges the open PR first; the next cron run then syncs the next tag. Simple, never drops commits, but a backlog pauses syncing until the human acts. - (b) Merge intermediate tags in sequence. Instead of leaping to the newest tag, merge the oldest unmerged upstream tag onto the existing sync branch (or chain branches), so no tag is skipped. More automation, more conflict surface, but keeps moving without a human. Do not silently fast-forward to the newest tag and abandon a stranded prior PR — that is the one behavior that loses upstream commits. The §6.2 runbook carries a "a previous sync is still open/blocked" branch with this resolve-in-order-vs-fast-forward decision, and the §4.5 table notes that a paused backlog compounds the next merge's conflict effort (two upstream deltas resolved at once).
BLOCKING gap — a CLOSED-unmerged sync PR silently defeats resolve-in-order and drops commits; and the labels must exist and fail closed. The two guards above are insufficient on their own: Guard 1 skips only an already-merged tag, and Guard 2 bails only while a sync PR is open. So if a maintainer closes a noisy/conflicted sync PR to defer it (instead of leaving it open), the intervening tag is neither an ancestor of
masternor open — both guards pass, the next cron leaps to the newest tag, and the closed tag's commits (including the security fixes that are the whole reason — §0 — to track upstream) are silently dropped. Separately, Guard 2 and the §0.1 trip-wire query thesync/conflict/chokepoint-refreshlabels, butgh pr list --label syncreturns an empty list, not an error, for a label that was never created — so on a fresh repo Guard 2 silently no-ops until someone creates the label. Both failures are silent and surface only as missing upstream fixes much later — the worst class for a set-and-forget loop. Harden the design so the loop never relies on PR open-state to decide what to sync:
- (a) Track the last-SYNCED upstream tag in a committed state file (
.fork-sync-state), advanced ONLY when a sync PR merges tomaster(a tinyci_cd.ymlpush-job step writes the merged tag and commits it). The cron then syncs the oldest upstream tag strictly newer than the recorded tag — not "newest tag minus--is-ancestor". A closed-unmerged PR can no longer cause a skip (the state file didn't advance, so that tag is still "next"), and resolve-in-order holds by construction. This subsumes Guard 1.- (b) Create the labels at bootstrap (
gh label create sync|conflict|chokepoint-refresh, §6.1) and make Guard 2 / the trip-wire fail closed: if the label query errors OR the label is absent, bail and alert, never treat an empty result as "no open sync."- (c) A deferred sync PR must be left OPEN — never closed-and-forgotten. If it genuinely must be closed, the recorded tag in
.fork-sync-stateis not advanced (it only advances on merge), so the next cron re-opens that same tag rather than skipping it. The §6.2 runbook states this explicitly.
CRITICAL — the in-repo
schedule:trigger silently auto-disables after 60 days of repo inactivity. GitHub Actions disables aschedule:trigger defined in a repository that has had no commit activity for 60 days, with no error, no PR, and no notification. This failure is self-camouflaging: the quieter upstream is, the more likely a low-effort fork goes 60 days without a commit, and that is exactly when the cron stops firing — so the fork can fall arbitrarily far behind on upstream security/bug fixes with no signal, directly contradicting "continuously merge … indefinitely." The cron alone is an unmonitored single point of failure. Pick at least one durability mechanism (recommend the first two together):
- Heartbeat / re-arm. A lightweight scheduled job that keeps the repo "active" — e.g. a periodic no-op commit to a tracking file, or a
gh workflow enablere-arm call — so the 60-day clock never expires. (A heartbeat job is itself aschedule:and can also be disabled; prefer the external scheduler below as the primary, with the heartbeat as belt-and-suspenders.)- Liveness alert (two triggers, not one). Fail loudly if EITHER: (i) no sync PR has appeared in N days (e.g. N=45, inside the 60-day window) — catches a dead cron; OR (ii) the oldest OPEN
sync/*PR is older than M days (e.g. M=14) — catches a sync that opened but then stalled/blocked, which the "no PR in 45 days" trigger alone would NOT see (an open-but-stale PR keeps resetting the "newest PR" clock while no upstream commits actually land). Without trigger (ii) a zombie PR keeps the alert silent while the fork falls behind. A tiny job opens an issue / sends a notification on either condition.- External scheduler (most robust — RECOMMENDED PRIMARY). Move the trigger off in-repo
schedule:entirely — an org-level scheduled dispatch, a separate always-active scheduler repo, or an external cron hittingrepository_dispatch. An out-of-repo trigger is not subject to the 60-day in-repo inactivity rule. For a set-and-forget loop this is the default to pick, for two reasons: (1) it is the only option immune to the 60-day auto-disable, and (2) if the external trigger carries its own credential, it composes naturally with theSYNC_BOT_TOKENfix above (the same fork-owned credential that re-triggers the guard/CI checks) — one credential solves both "the cron stays alive" and "the PR runs CI." Keep the in-reposchedule:+ heartbeat + liveness alert as belt-and-suspenders, not as the sole mechanism.At minimum, document the 60-day auto-disable as a known failure mode in the runbook (§6.2) and add the liveness alert; treating the bare cron as "set and forget" is unsound for an indefinite tracking loop.
CRITICAL — the sync PR currently runs NO CI (verified): upstream's
ci_cd.ymlis triggeredon: push: branches: [master]ONLY — there is nopull_request:trigger andci_cd.ymlis the sole workflow (grep -rln pull_request .github/workflows/returns nothing). As shipped, therefore, the §4.4 anti-relock test (Guard A) and the §4.4 marker grep (Guard B) never run on the sync PR. They run only post-merge, on the push tomaster— the same push that proceeds straight through build → sign → notarize →semantic-release→softprops/action-gh-release. There is no pre-merge automated gate and no green check for the human reviewer to rely on. A relock would therefore be caught only after the release has already been cut. This must be fixed before the strategy is safe (see the requiredpull_request:trigger below); until then, "the PR's CI gates the release" is FALSE.
MANDATORY BOOTSTRAP DELIVERABLE (not a "required fix" footnote) — make the release gate real. The
entire "resolve-and-ship with confidence" strategy (§4.1/§6.2) leans on a pre-merge gate that does NOT exist
as upstream ships ci_cd.yml. Two pieces, both first-class bootstrap items with an acceptance test:
(1) Add a pull_request: trigger to the fork's ci_cd.yml and gate EVERY release-side step.
on:
push:
branches: [master]
pull_request:
branches: [master]
Gate the COMPLETE set of release/push-only steps under if: github.event_name == 'push' — derived
line-by-line from the actual workflow (verified ordering, ci_cd.yml:47-67):
Step (ci_cd.yml line) |
On pull_request? |
Why |
|---|---|---|
npm ci (:47) |
runs | needed to install commitlint/semantic-release tooling |
commitlint --from "$GITHUB_EVENT_BEFORE" --to "$GITHUB_EVENT_AFTER" (:48) |
GATE or fix | $GITHUB_EVENT_BEFORE/AFTER (env, :14-15) are push-context and empty on pull_request, so this lints an empty/garbage range and goes red for reasons unrelated to a relock |
ensure_generated_files_are_up_to_date.sh (:49) |
runs | legitimate PR check (catches an un-regenerated l10n diff — §4.5) |
determine_next_version.sh (:50, semantic-release --dry-run) |
GATE | a dry-run against a PR head is meaningless and can fail/mis-bump; it is release-path, not a gate |
replace_environment_variables_in_app.sh (:51) |
GATE | writes build config; release-only |
setup_ci_master.sh (:52) |
GATE (use setup_ci_pr.sh if a PR build is wanted — scripts/codesign/setup_ci_pr.sh exists) |
signing setup |
| Guard B + Guard C greps (inserted, FIRST — before :53) | runs | marker + conflict-marker greps; placed first so on the push path they abort before update_appcast/semantic-release sign/push (§4.4) |
run_tests.sh (:53) — Guard A |
runs | the anti-relock unit test; the behavioral gate |
build_app.sh (:54) |
GATE | build |
package_and_notarize_release.sh (:55) |
GATE | notarize |
upload_symbols_to_appcenter.sh (:56) |
drop entirely | telemetry — removed |
update_appcast.sh (:57) |
GATE | publishes feed |
update_readme_and_website.sh (:58) |
GATE/drop | website |
npx semantic-release (:59) |
GATE | cuts release |
extract_latest_changelog.sh (:61) |
GATE | sets release-body outputs |
softprops/action-gh-release (:62) |
GATE | creates release |
update_website.sh (:67) |
GATE/drop | website dispatch |
The idealized "PR path runs only
npm ci→ensure_generated→run_tests→ grep" sentence in earlier drafts was WRONG:commitlint(:48) anddetermine_next_version(:50) sit in front ofrun_tests(:53) and misbehave on PR events. Either gate them underif: github.event_name == 'push'too, or give them PR-safe variants — for commitlint, lint a base..head range valid on PR context (--from ${{ github.event.pull_request.base.sha }} --to ${{ github.sha }}, paired with the §4.3ignorespredicate) ornpx commitlint --last; do NOT leave them running over an empty push-range on PR events, or the PR check goes red for non-relock reasons — which either blocks every clean sync or trains the maintainer to merge past a red check (defeating the whole gate). The exact ordered PR-event step list is: Guard B + Guard C greps (first, near-instant) →npm ci→ensure_generated_files_are_up_to_date.sh→ (commitlint, PR-safe variant or gated) →run_tests.sh(Guard A). (On the PR path nothing is signed or pushed, so grep ordering is less critical there than on the push path — but keeping the greps first is consistent with the push job and gives the fastest red signal.)
Acceptance test for this deliverable (do it once, at bootstrap): push a branch that deliberately reverts
the computeState() → .pro flip and open a PR; confirm the PR check is RED (Guard A fails, or Guard B's
marker grep fails) before any merge. A green check on a relocked branch means the gate is not wired and the
bootstrap is incomplete.
(2) Add a SEPARATE fork-owned guard workflow .github/workflows/guard.yml on pull_request that runs
Guard A + Guard B independently of ci_cd.yml. Rationale: ci_cd.yml is the one file most likely to take an
upstream conflict, and a careless "keep ours/theirs" resolution could silently strip the pull_request:
trigger or the gating — at which point the §4.4 guards would stop gating PRs with no error. A guard workflow
that does not inherit ci_cd.yml's trigger survives that. Pair it with the §6.2 recurring check that the
pull_request: trigger still exists in ci_cd.yml post-merge. And independently, wire the Guard B + Guard C greps
into ci_cd.yml as the FIRST push-job steps — before run_tests.sh, and therefore before update_appcast.sh (Sparkle sign), semantic-release (tag push), and softprops/action-gh-release (§4.4) — so that
even with no PR gate at all, a relock or conflict-laden tree aborts the job before the tag is pushed or the
binary signed. Placing the grep only just before action-gh-release is too late (the tag is already
pushed by semantic-release at line 59 — §4.4 "Where the guards run"); running it first makes "a relock can
never ship" robust to a missing or misconfigured trigger, not contingent on it.
A release fires only when the PR merges to master. The sync vs. sync,conflict label tells the
maintainer whether it's a 5-minute approval or a chokepoint refresh.
4.3 Auto build → sign → notarize → appcast → release
Keep ci_cd.yml's skeleton; change secrets, identity URLs, versioning scope — and add two pieces of
fork-side plumbing the upstream workflow assumes exist:
Workflow permissions + Environment (verified gaps):
ci_cd.ymlhas nopermissions:key.@semantic-release/gitpushes commits+tags tomasterandsoftprops/action-gh-release@v3(line 62) creates the release (release.config.jshas no@semantic-release/githubplugin, so softprops is the sole release-creator). Both needcontents: write. GitHub defaults the workflowGITHUB_TOKENto read-only since 2023, so without write perms the push and the gh-release creation 403 → no tag, no release, and the appcast enclosure (releases/download/v$version/…) then 404s for auto-update. Addpermissions: { contents: write }to the fork'sci_cd.yml(or set the repo default workflow token to read-write).ci_cd.yml:23setsenvironment: production. Create a GitHub Environment namedproductionin the fork and attachAPPLE_P12_CERTIFICATE/APPLE_ID/APPLE_PASSWORD/APPLE_TEAM_ID/SPARKLE_ED_PRIVATE_KEYthere (matching theenvironment:), with no required-reviewer protection that would stall releases.
Node version (verified mismatch — do NOT treat as a one-line bump): ci_cd.yml:43 pins
node-version: 16 while package.json requires "node": ">=18.0.0". But npm ci only warns on an
engines mismatch — it does not hard-fail unless engine-strict=true is set (it is not). And the release
toolchain pinned in package.json is 2018-2019-era: semantic-release ^15.13.24, @commitlint/cli ^8.1.0,
husky ^3.0.4 (all consistent with the node-16 pin). semantic-release v15 and commitlint v8 were never
validated on Node 18/20, and both run on the critical release path (npx semantic-release,
commitlint), so a naïve node bump can trade a benign warning for a semantic-release crash after
build/notarize already ran. Choose one, don't present node-18 as a one-liner:
- (lower-risk) KEEP
node-version: 16and delete the bump — the engines mismatch only warns; OR - If bumping, treat it as a toolchain-upgrade subtask: bump
semantic-release(→ v22+), commitlint (→ v18+, which changes the config format — coordinate with the §4.3 commitlintignoresfix), and husky (→ v9) together, and re-validate the entire release chain on the first run.
APP_NAME: set ci_cd.yml:19 APP_NAME equal to PRODUCT_NAME (§3.1) or the build/package/appcast
scripts mismatch the artifact.
Secrets — same names, fork's values (repo Settings → Secrets → Actions, and the production
Environment above):
APPLE_P12_CERTIFICATE,APPLE_ID,APPLE_PASSWORD,APPLE_TEAM_ID— the fork owner's Developer ID Application cert + notarization credentials.setup_ci_master.shandpackage_and_notarize_release.shconsume them unchanged. Per the AGENTS.md invariant, the Developer ID / TeamID / bundle id must stay stable across the fork's own builds or every user's license keychain item orphans — freeze the fork identity once.SPARKLE_ED_PRIVATE_KEY— new EdDSA keypair for the fork; public half goes to theSUPublicEDKeyvalue onInfo.plist:62(<key>is on :61). Mandatory: Sparkle verifies the appcast against the embedded public key.APPCENTER_SECRET/APPCENTER_TOKEN— leave unset; dropupload_symbols_to_appcenter.sh.WEBSITE_DISPATCH_TOKEN— drop with theupdate_website.shstep.
Identity/script edits: local.xcconfig DOMAIN (§3.1); scripts/update_appcast.sh:18 enclosure URL
→ the fork's releases (safety-critical); remove/repoint update_website.sh + the lwouis API calls in
update_readme_and_website.sh.
Versioning + commitlint (verified, operationalized): release.config.js has no repositoryUrl pin
and @semantic-release/git commits back to the CI branch, so semantic-release versions off the fork's
own tags. Fork versions are independent and each chore(sync) merge intentionally cuts a fork release:
release.config.js maps chore → patch (line 12), so the synthetic chore(sync): merge upstream commit
forces at least a patch bump, and the merged upstream feat:/fix: commits in the range drive the bump
type. sparkle:version (from VERSION.txt via update_appcast.sh) tracks the fork tag. (1) The fork's
extra depaywall:/identity commits guarantee a version > the synced upstream tag, so no special suffix is
needed for ordering — but the fork's own vX.Y.Z tags will diverge from / leapfrog upstream's, so a future
git merge v11.X.0 can collide in the tag namespace if the fork already cut its own v11.X.0. A collision
hard-stops a sync (git merge of an upstream tag whose name the fork already owns), so this must be
decided at bootstrap, not left optional.
DECISION (bake this in at §6.1, do not defer): adopt a permanent fork version offset — the fork's release line starts at
v100.0.0and semantic-releases from there, so a fork tag and an upstreamv11.x/v12.xtag can never share a name for the foreseeable future. (Alternative if a distinct prefix is preferred: configure semantic-release'stagFormatto afork-vX.Y.Znamespace; the offset is simpler and keeps Sparkle's numericsparkle:versionordering monotonic.) Set this once and the sync loop can never deadlock on a namespace clash. A trailing+forkbuild-metadata suffix is NOT sufficient on its own — SemVer ignores build metadata for precedence andgit tagnames still collide on thevX.Y.Zstem. (2)commitlintlints the whole pushed range (ci_cd.yml:48,--from "$GITHUB_EVENT_BEFORE" --to "$GITHUB_EVENT_AFTER"); a merged upstream tag drags in upstream's commit messages, some non-conforming → commitlint fails the build (blocking the release job) and/orcommit-analyzermis-bumps.
Concrete fix (PRIMARY — commitlint.config.js ignores; verify on the FIRST sync): the fork's
commitlint.config.js currently just extends: ['@commitlint/config-conventional'] (verified). Add an
ignores predicate that drops the synthetic merge commit AND any commit reachable only through the merge's
second parent (i.e. upstream's own messages), while keeping defaultIgnores: true so config-conventional's
built-in "Merge …" ignore still applies:
module.exports = {
extends: ['@commitlint/config-conventional'],
defaultIgnores: true,
ignores: [
(msg) => /^chore\(sync\): merge upstream /.test(msg), // our synthetic merge commit
(msg) => /^Merge /.test(msg), // belt-and-suspenders
// upstream messages we don't author and can't conform — match the patterns your
// upstream actually uses, or pair this with the --last range narrowing below.
],
}
Do NOT use
--from HEAD~1 --to HEAD(verified BROKEN). Over a--no-ffmerge,HEAD~1resolves to the FIRST parent (the fork tip), andgit rev-list HEAD~1..HEADenumerates the merge commit PLUS every newly-reachable upstream commit through the second parent — empirically 3 commits in a scratch test (chore(sync): merge upstream v11.4.0,WIP messy upstream commit,feat: upstream A), not 1. commitlint reads the samefrom..torange, so a non-conforming upstream commit still fails the job. This form does NOT lint "only the merge commit."
If a range-narrowing command is still wanted (e.g. to lint just the synthetic merge subject), use one
that lints exactly one message: npx commitlint --last --verbose (lints HEAD only) — not a .. range over a
merge. Because the push re-lints the whole range, the §6.2 conflict-resolution commits must also stay
conventional.
Net diff vs. upstream ci_cd.yml: identical skeleton; drop upload_symbols_to_appcenter.sh +
update_website.sh; softprops/action-gh-release publishes to the fork's repo via the ambient
GITHUB_TOKEN; secret values differ; add the anti-relock test step (§4.4).
4.4 Safety — anti-relock smoke test (the backstop)
A bad merge that re-introduces a working paywall must never push a tag, sign a binary, or write an appcast
entry — not merely "never reach action-gh-release." (The irreversible side-effects happen at
update_appcast.sh/semantic-release, lines 57/59, before action-gh-release at 62; the greps therefore
run FIRST — see "Where the guards run" below.) Two independent, cheap guards — a unit test that exercises the
patched producer, plus a target-independent CI grep:
Guard A — unit test (append to the EXISTING LicenseManagerTests.swift; zero pbxproj edits)
The obvious test does NOT work — verified four ways. Do not write it. A naïve
AntiRelockTeststhat (1) referencesProFeature.*.attemptUse()won't even link:ProFeature.swiftis in the app target's Sources phase (project.pbxproj:1970), NOT theunit-testsphase (BF0C80F5, lines 1829-1900) — that phase hasLicenseManager.swift,LicenseState.swift,Endpoints.swift,RemoteLicenseClient.swiftbut noProFeature.swift. (2) Asserting onLicenseManager.sharedfails even on a CORRECTLY patched build:.sharedis neverinitialize()d in tests (the only caller isApp.swift:460), so itsstatestays at the.trialExpireddefault (LicenseManager.swift:51) →isProLocked == true. The patch flipscomputeState(), which the test never invokes. (3)LicenseManager.shared's lazy init eagerly buildsRemoteLicenseClient(baseUrl: Endpoints.licenseApiBaseUrl), andEndpoints.swift:4-5force-unwrapsBundle.main.object(forInfoDictionaryKey: "Domain"/"ApiDomain") as! String. Theunit-teststarget hasGENERATE_INFOPLIST_FILE = YESand noTEST_HOST/BUNDLE_LOADER(project.pbxproj:2473,2477) → a standalone xctest bundle whose auto-generated Info.plist has noDomainkey →fatalError/ crash. (4)!Endpoints.licenseApiBaseUrl.contains("alt-tab.app")is trivially true once §3.1 repointsAPI_DOMAIN(and the base value isalt-tab.app/api, so the substring match is fragile anyway). Net: that test turns CI permanently red on every sync, not just on a relock.
Instead, exercise the patched producer on a mock-injected instance — exactly the pattern the existing
tests use (LicenseManagerTests.setUp, lines 11-19). Add these methods to the existing
src/pro/license/LicenseManagerTests.swift (already in the unit-tests Sources phase,
project.pbxproj:1892), so no new file and no pbxproj edit is needed, and the "zero pbxproj edits"
invariant stays literally true:
// alt-tab-free [depaywall]: anti-relock backstop. If a merge re-arms the paywall, these fail
// BEFORE build/sign/notarize (run_tests.sh runs the Test scheme before build_app.sh).
// Uses a fresh mock-injected manager (NOT LicenseManager.shared, which is never initialize()d in
// tests and would force-unwrap Bundle.main "Domain" in the host-less xctest bundle). All symbols
// here (LicenseManager, LicenseState, MockClock, MockKeychain, MockLicenseAPI) ARE in the unit-tests
// target; ProFeature is NOT, so do not reference it. Use the existing MockClock fixture (not
// SystemClock()) so the trial-expiry math is deterministic, not wall-clock-dependent.
// Use the EXISTING MockClock fixture (LicenseManagerTests.swift:475 — already in this file/target), NOT
// SystemClock(), so trial math is deterministic and not coupled to wall-clock time.
func testDepaywallProNeverLocked() {
// LOAD-BEARING assert: on unpatched code a fresh MockKeychain → computeTrialState() → .trial(14),
// so this fails iff the forced `return .pro` is missing.
let m = LicenseManager(clock: MockClock(now: Date(timeIntervalSince1970: 1_700_000_000)),
keychain: MockKeychain(),
api: MockLicenseAPI(),
defaults: UserDefaults(suiteName: "depaywall-\(UUID().uuidString)")!)
m.initialize() // routes through the patched computeState()
XCTAssertEqual(m.state, .pro,
"PAYWALL RELOCKED: computeState() no longer returns .pro — refresh the de-paywall patch.")
}
// SECOND, DISCRIMINATING test: inject an EXPIRED trial so isProLocked is meaningful. On a fresh
// MockKeychain the day-0 trial is .trial(14) — for which isProLocked==false and isProAvailable==true
// REGARDLESS of the patch — so asserting them on a fresh manager proves nothing. Force a long-past
// trialStartDate (well beyond trialDuration=14) so the UNPATCHED computeTrialState() returns
// .trialExpired (isProLocked==true, isProAvailable==false); only the forced .pro makes these pass.
// MockClock pins "now" so the expiry is deterministic regardless of when CI runs.
func testDepaywallStillProAfterTrialExpiry() {
let defaults = UserDefaults(suiteName: "depaywall-\(UUID().uuidString)")!
defaults.set(Date(timeIntervalSince1970: 0).timeIntervalSince1970, forKey: "trialStartDate") // ~1970 → expired
let m = LicenseManager(clock: MockClock(now: Date(timeIntervalSince1970: 1_700_000_000)),
keychain: MockKeychain(),
api: MockLicenseAPI(),
defaults: defaults)
m.initialize()
XCTAssertEqual(m.state, .pro, "PAYWALL RELOCKED: expired trial no longer forced to .pro.")
XCTAssertFalse(m.isProLocked, "PAYWALL RELOCKED: isProLocked true on an expired trial.")
XCTAssertTrue(m.isProAvailable, "PAYWALL RELOCKED: isProAvailable false on an expired trial.")
}
A fresh MockKeychain has no license key, so on the unpatched code computeState() would fall to
computeTrialState() → .trial (day 0) and the assert on .pro fails loudly — i.e. the first test
genuinely detects a relock and passes only when the forced return .pro is present. Verified subtlety:
on a day-0 trial isProLocked == false and isProAvailable == true even on unpatched code (.trial
returns false/true in LicenseManager.swift:69 / LicenseState.swift:8), so asserting those two on a fresh
manager is non-discriminating — that is why the load-bearing assert is state == .pro, and the second
test injects an expired trialStartDate to make isProLocked/isProAvailable meaningful. It depends only on the chokepoint
behavior via the public producer, so it survives upstream refactors that don't re-lock. Do not call
mockProUser() (it is #if DEBUG; run_tests.sh builds -configuration Release, which strips DEBUG —
the call site would not compile). Do not reference LicenseManager.shared, Endpoints.*, or
ProFeature.* (the four traps above).
Guard B — target-independent CI grep (catches what a unit test structurally can't)
The unit-tests target deliberately excludes ProFeature/ProTransitionManager/PreferenceDefinition
(pure *Testable shims), so a unit test can only ever cover LicenseManager-level behavior. Add a
build-target-independent step to the fork's ci_cd.yml as the FIRST step of the push job — before
run_tests.sh (line 53), and therefore before update_appcast.sh (57, Sparkle sign), npx semantic-release
(59, tag push), and softprops/action-gh-release (62) — that fails the job if the forced flip is missing.
Placement matters: a grep that runs only at line 62 fires after the tag is already pushed and the binary
already signed (see §4.4 "Where the guards run"), so it must run first to actually prevent a relock from
shipping. The grep is near-instant, so running it first is free:
Verified pitfall — a bare
return .progrep matches the UNPATCHED file.computeState()already containsreturn .proatLicenseManager.swift:187(the keychain-valid branch) on a fully relocked tree, sogrep -Eq 'return[[:space:]]+\.pro'passes even when the paywall is armed — it is not load-bearing. The only load-bearing grep is thedepaywallmarker. Make the forced-return check co-locate with the marker (e.g. require the marker line and anif falseguard in the same file) so it actually distinguishes patched from unpatched:
- name: anti-relock — verify de-paywall marker present
run: |
grep -q 'alt-tab-free \[depaywall\]' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: depaywall marker missing from computeState()"; exit 1; }
# The marker proves the patch is present; pair with the if-false guard the wrap introduces.
grep -Eq 'if[[:space:]]+false[[:space:]]*\{' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: 'if false { … }' wrap missing from computeState()"; exit 1; }
Do NOT rely on a bare
return .progrep (it matches the unpatched line 187).
Guard C — conflict-marker gate (keeps a conflicted tree from ever being published)
The §4.2 cron deliberately commits a conflicted tree (markers included, --no-verify) to the sync
branch and pushes it for a human to resolve. Nothing in the design otherwise guarantees the human's
resolution removed every marker before merge — and a markers-laden intermediate commit, if merged, becomes
a permanent ancestor of the published tag, contradicting §5(1)'s "the tag tree IS the Corresponding
Source" claim (you'd be shipping un-compilable source). Add a cheap tree-wide conflict-marker grep to BOTH
guard.yml (pre-merge) and ci_cd.yml as a first push-job step — before run_tests.sh/update_appcast.sh/semantic-release (NOT merely before softprops/action-gh-release, which runs after the tag is
already pushed — §4.4 "Where the guards run"):
- name: anti-conflict — no unresolved merge markers
run: |
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
echo "UNRESOLVED CONFLICT MARKERS present — resolve before merge/release"; exit 1
fi
(Exclude docs/ so this plan's own literal examples of markers don't trip it; tune the pathspec to taste.)
Define in §6.2 that conflict resolution amends/replaces or squashes out the bot's --no-verify conflict
commit so the published tag history is clean — the conflicted commit must not survive into master.
This avoids the pbxproj-membership and Bundle.main-plist pitfalls entirely and gives an extra-loud signal
a skipped/disabled unit test can't silently bypass. (The §2 endpoint surface — the
<bundleid>://activate handler, where the registered scheme is $(PRODUCT_BUNDLE_IDENTIFIER), is
scheme-gated, not state-gated, so it stays live and POSTs the machine hardware UUID — is handled by
neutralizing handleCustomUrl (§2 default), independent of API_DOMAIN, not by a network assertion
here.)
Where the guards run — defense in depth (so a missing trigger can't unship the safety)
The guards are wired in three places so no single misconfiguration disarms them:
-
.github/workflows/guard.yml(fork-owned,on: pull_request) — runs Guard A (run_tests.shor a focusedxcodebuild test) + Guard B grep + Guard C conflict-marker grep. This is a SEPARATE workflow that does not inheritci_cd.yml's trigger, so an upstreamci_cd.ymlconflict resolved "keep ours/theirs" that accidentally strips thepull_request:trigger cannot silently disable the PR gate. This is the reviewer's primary green/red signal.Guard A MUST run on
macos-15(the pinned Xcode), NOT ubuntu.run_tests.shisxcodebuild test— it only runs on a macOS runner with the §4.5/§6.1-pinned Xcode (Xcode_26.0.1). Ifguard.ymlis left on the cheaperubuntu-latest, Guard A silently degrades to grep-only (the xcodebuild step can't run), leaving the behavioral relock test unexecuted — the exact silent-disarming class this plan is otherwise careful about. Split the workflow if you want speed: run Guard B/C greps onubuntu-latest(near-instant) as one job, and Guard A (xcodebuild test) onmacos-15as a separate job. Bootstrap assertion: on the deliberately-relocked acceptance PR (§4.2), confirmguard.ymlactually executed a build+test onmacos-15(not just the greps) — i.e. Guard A produced a real test result, red on the relock. -
ci_cd.ymlpull_request:path (§4.2) — the same guards, in the fork's main workflow, for the complete CI signal on the PR. -
ci_cd.ymlpush path, Guard B + Guard C greps wired in as the FIRST job steps — beforerun_tests.sh(line 53), and therefore unconditionally beforeupdate_appcast.sh(line 57, Sparklesign_update+ appcastITEMappend),npx semantic-release(line 59, which pushes thevX.Y.Ztag tomaster), andsoftprops/action-gh-release(line 62) — the release-time backstop, placed so it aborts the job before any irreversible side-effect. This one is the safety net that makes the failure mode fail-closed: even if BOTH PR-trigger paths above were misconfigured or stripped, a relocked tree (Guard B) or a tree still carrying conflict markers (Guard C) aborts the run before the tag is pushed and before any binary is signed.WHY THE PLACEMENT IS LOAD-BEARING (verified
ci_cd.ymlordering — this corrects an earlier draft). An earlier version of this plan placed Guard B/C "immediately beforesoftprops/action-gh-release(line 62)" and claimed that made "even with no PR gate, a relock cannot ship." That claim was FALSE as written. Verified step order in the push job:run_tests.sh(53) →update_appcast.sh(57, which runs Sparklesign_updateand appends the signedITEMto the appcast feed) →npx semantic-release(59, which via@semantic-release/gitcommits and pushes thevX.Y.Ztag tomaster) →extract_latest_changelog.sh(61) →softprops/action-gh-release(62). So a grep firing only at line 62 runs after the tag is already an immutable ancestor ofmasterand after the binary is already EdDSA-signed and the appcast entry written — i.e. the exact §5(1) "shipping un-compilable Corresponding Source" / paywalled-relock outcome has already happened before the grep fires; only the GitHub Release object and the website dispatch would be blocked, not the tag push or the in-process appcast signing. Because Guard B/C are target-independent greps that run in well under a second, running them as the job's first steps costs nothing and is the only placement that makes the "cannot ship" guarantee literally true (the job aborts pre-tag-push, pre-sign). Keep a second copy of the greps immediately beforeaction-gh-release(line 62) as belt-and-suspenders, but the load-bearing copy is the one at the top of the job.State this explicitly: Guard B + Guard C run as the first push-job steps, before
run_tests/update_appcast/semantic-release, so even with no PR gate a relocked or conflict-laden tree aborts the job before the tag is pushed or the binary signed — it cannot ship. The §6.2 runbook adds a recurring check that thepull_request:trigger still exists post-merge, so the pre-merge layers don't silently rot.
Per-release feature-unlock acceptance check (positive proof the gates are actually open)
Guard A asserts only at the LicenseManager layer and Guard B/C are greps — but §2 itself warns the
gate-consumers (ProFeature / PreferenceDefinition / ShortcutAction / TilesView) are NOT in the
unit-tests target and can silently re-lock while Guard A stays green (a new entitlement check, a gate
re-pointed off state, a new LicenseState case). The compile + Guard A provably cannot cover those
consumers. Close the gap with a short positive acceptance checklist on each release — confirm each
headline Pro feature is actually FREE in the BUILT app, not just that state == .pro in a mock:
- Assign a 9th+ custom shortcut (free users were hard-capped at 1 —
AUDIT.md/ControlsTab.swift). - Type-to-search in the switcher works.
- A Pro appearance style (e.g. a gated
appearanceStyle/appearanceSize) is selectable and sticks. - At least one other gated feature from the §4 audit list of your choosing.
- No inert Pro UI renders an "AltTab" brand string — since §2 Edit-5 deliberately leaves some inert upsell UI present-but-unreachable, spot-check that no still-visible Pro surface (e.g. a residual "Get Pro" menubar item) shows the upstream product name. If one does, either it is reachable (a relock/feature bug) or the §3.2 inert-UI-unreachable assumption is wrong — both warrant action. This is manual or UI-level (XCUITest if the owner wants it automated later); it is fast and it is the only layer that exercises the consumers end-to-end. The §6.2 gate-consumer review (step 5) is promoted from conditional-manual to a mandatory per-release item for the same reason — see §6.2.
4.5 Effort estimate
| Case | Frequency | Per-sync effort | What happens |
|---|---|---|---|
| Cold path (chokepoints untouched) | Overwhelming majority — 34/34 commits since v11.0.0 left all four at zero edits | ~5–15 min, mostly review | Cron opens a clean (or identity-only-conflict) PR; maintainer resolves any local.xcconfig/Info.plist/CI/update_appcast.sh/release.config.js collisions ("keep ours"), confirms green CI incl. anti-relock test + grep, merges. Pipeline auto-releases. If the sync touched any src/*.swift with NSLocalizedString (e.g. an upstream string change near a swept literal), re-run scripts/l10n/extract_l10n_strings.sh and commit the regenerated resources/l10n/*.strings or the ensure_generated_files_are_up_to_date.sh gate (ci_cd.yml:49) fails the PR. |
| Identity-conflict subset | Whenever upstream edits a line the fork also changed | included above | git checkout --ours on those files, re-confirm fork URLs/keys, commit. No code logic. |
| Chokepoint-moved path | Rare — zero so far | ~30–90 min | Merge halts in the chokepoint file (loud, by design). Re-apply de-paywall intent, run anti-relock test locally until green, commit. The test guarantees a mis-resolution can't ship. |
| l10n-regeneration (BASELINE, not a caveat) | Recurs whenever a sync touches any of the ~18 swept src/*.swift files near an NSLocalizedString, or the .strings files |
+ time per affected sync | The §3.2 string sweep makes ~18 src/*.swift files + l10n entries warm. ensure_generated_files_are_up_to_date.sh (ci_cd.yml:49) hard-fails on any uncommitted regenerated diff, and the regeneration must byte-match upstream's output — so the fork runner's Xcode/genstrings must match upstream's pinned Xcode (ci_cd.yml:45, Xcode_26.0.1). Re-run scripts/l10n/extract_l10n_strings.sh and commit the regenerated resources/l10n/*.strings before pushing. Minimize the sweep to shrink this warm surface. |
Baseline note: the l10n-regeneration cost above is a consequence of the trademark sweep and recurs on the cold path too — it is part of the steady-state baseline, not a rare edge case. Pinning the fork runner's Xcode to upstream's (
ci_cd.yml:45) and keeping the sweep minimal are the levers that keep it cheap.
CI-minutes note (macOS-runner cost per sync, not wall-clock effort). Guard A in
guard.ymlrunsxcodebuild testonmacos-15(§4.4 layer 1), and theci_cd.ymlPR path builds too — macOS runner minutes are ~10× the ubuntu rate on GitHub-hosted runners. Every sync PR therefore consumes a macOS build, and a re-pushed conflict resolution consumes another. The §4.2 cron Guards 1/2 (skip already-merged tags; don't re-open while a PR is open) are what keep this from multiplying into a daily no-op macOS build — without them the daily cron would burn a macOS build every day. Budget for "a macOS build per sync PR plus re-pushes," and keep the greps onubuntu-latestso a relock fails before the expensive macOS job starts.
4.6 Credential / cert / Sparkle-key lifecycle (periodic overhead, independent of merge cadence)
The merge loop is not the only recurring cost. Three credentials expire or can break on their own clock, unrelated to upstream activity, and an unhandled lapse silently breaks releases or auto-update. Track them as a separate "periodic overhead" bucket (fold into the §4.5 effort picture, do not hide inside the per-merge estimate):
- Developer ID Application cert (~yearly) + Apple Developer Program membership (yearly) +
app-specific password. On expiry, notarization/signing fails and no release can be cut. On cert
rotation: re-export the new p12 → base64 → update the
APPLE_P12_CERTIFICATEsecret in theproductionEnvironment, and re-sign + re-notarize — note this intersects the §3.3 Sparkle helper re-sign: the bundledvendor/Sparkle/Helpers/*must be re-signed under the new cert too, or the nested-Mach-OTeamIdentifiercheck fails. Keep the AGENTS.md identity invariant in mind: the TeamID/bundle id stay frozen across cert renewals (a renewed cert under the same Team is fine; a new Team orphans every user's keychain/identity). - Sparkle EdDSA key — break-glass plan (there is no clean rotation). The public key is baked into shipped
binaries (
Info.plist:62 SUPublicEDKey); a compromised or lost private key cannot be rotated without orphaning every already-installed client, because existing installs only trust the embedded public key and will reject an appcast signed by a new key. Plan: storeSPARKLE_ED_PRIVATE_KEYdurably (the loss case is unrecoverable), and document that recovery from a lost/compromised key requires shipping a hard-coded migration build out-of-band (a new download users must install manually, carrying the new public key) — auto-update cannot bridge the gap. This is a known, accepted single point of failure; back up the key. - Notarization credential drift.
APPLE_ID/APPLE_PASSWORD(app-specific password) get revoked when the Apple ID password changes or the app-specific password is regenerated; refresh the secret when that happens, orpackage_and_notarize_release.shfails at the notarytool step. - Sync-bot credential (
SYNC_BOT_TOKEN) — a new silent SPOF (§4.2). The GitHub App installation token / PAT that authors the sync push + PR expires (PATs on their set lifetime; App tokens auto-rotate but the App install can be revoked). On expiry the cron's push/PR-create starts failing — but the more dangerous failure is silent: if it falls back to (or is mis-set to) the defaultGITHUB_TOKEN, the PR still opens but runs no checks (the §4.2 blocking issue). Prefer the short-lived, least-privilege GitHub App token over a classic PAT (a leaked PAT can push tomaster). Rotation runbook (a botched swap silently degrades to the defaultGITHUB_TOKEN— the §4.2 blocking failure — so do it deliberately):- PAT: set a calendar reminder ahead of the PAT's expiry (with lead time) to mint the replacement,
swap the
SYNC_BOT_TOKENsecret, and confirm the cron's next run still opens a PR with live checks — so there is no window where the cron silently falls back toGITHUB_TOKEN. Never let it expire unattended. - GitHub App installation token: the token auto-rotates, but the App install / permissions can be
revoked or narrowed. After any change to the App's installation or permissions, re-run the §4.2
acceptance test (open a bot PR; confirm
guard.yml/ci_cd.ymlPR checks actually appear) — a silently de-permissioned App produces a PR with no checks, exactly the disarmed-gate failure. Either way, the §4.2 acceptance test (a bot PR must show live checks) is the canary, and it is part of the §4.6 positive heartbeat (above).
- PAT: set a calendar reminder ahead of the PAT's expiry (with lead time) to mint the replacement,
swap the
Cadence: review these once per quarter (a calendar reminder), and unconditionally on any release failure at the sign/notarize/appcast steps.
POSITIVE loop-health heartbeat (consolidate the ~6 silently-failing mechanisms into one monitored surface). The steady-state loop depends on at least six independent pieces that each fail silently: the
SYNC_BOT_TOKENexpiring (§4.2), the in-reposchedule:auto-disabling after 60 days (§4.2), the fork-runner Xcode pin drifting from upstream (§4.5), the Developer ID cert / Sparkle key lapsing (§4.6), commitlintignoresgoing stale (§4.3), and the Sparkle-helper re-sign being missed on a bump (§3.3). The §4.2 liveness alert is absence-only — it fires when a PR doesn't appear — and so cannot distinguish "healthy and quiet" from "the alerting job itself died." Add a single positive heartbeat that must affirmatively report green on a schedule (a monthly issue comment or a status badge), proving: (1) the cron fired within the last N days; (2) the most recent bot PR had liveguard.yml/ci_cd.ymlchecks attached (the §4.2 token canary); (3) the Developer ID cert, Apple membership, and notarization credential are not within X days of expiry; (4) the fork runner's Xcode pin matches upstream'sci_cd.ymlvalue. A heartbeat that goes quiet is itself the alarm (a dead monitor is observable), which absence-only alerting can never achieve — and it doubles as the §0.2 successor-handoff "is this loop actually healthy?" dashboard.
5. GPL-3 compliance recap
The work remains GPL-3.0 (AltTab is GPL-3.0; see docs/PLAN-republish-free.md §A for the full treatment,
which this plan does not duplicate).
Which license actually governs — resolve the GPL-vs-MIT contradiction (do not leave it implicit).
LICENCE.mdis verbatim GNU GPL v3, butpackage.json:6declares"license": "MIT"for the application itself (namealt-tab-macos, not a dependency) — a conflicting, machine-readable, public license artifact (most plausibly a stalesemantic-release/npm scaffold default that upstream never reconciled). Every obligation below rests on "this is GPL-3.0," so the plan must reason to that conclusion against the in-repo statement that says otherwise: an unaddressed MIT declaration muddies which terms govern, hands the upstream author a colorable "licensing is ambiguous / the tooling says MIT" argument in a C&D, and is exactly what a GitHub DMCA reviewer would surface.
- Documented position (the basis for §5 below): the GPL-3.0
LICENCE.mdplus the in-appNSHumanReadableCopyright = "GPL-3.0 licence"notice (Info.plist:21, surfaced viaApp.licence) govern the software; thepackage.jsonlicensefield is a stray scaffold value, not a grant of MIT terms to the application.- Fork's corrective (bootstrap, §6.1): set the fork's
package.jsonlicenseto"GPL-3.0"to match the actual grant, and addpackage.jsonto the §6.2 "keep ours" recurring set so a future merge cannot re-introduce the MIT string.- Which grant to carry forward: the bare
GPL-3.0the in-app notice states is the safe floor. Do not assume an "or-later" relicensing right unless upstream's intended "version 3 only" vs "version 3 or later" can be confirmed from an authoritative source — inventing an "or-later" right would itself be a licensing misstatement.
The three obligations, briefly:
- Keep it open / same license. The fork's source stays public under GPL-3.0; ship the corresponding
source for every binary released. Keep
LICENCE.mdbyte-for-byte (never edit). §6 corresponding-source mechanic (corrected): the release asset is binary-only —ci_cd.yml:66attachesfiles: ${{ env.XCODE_BUILD_PATH }}/*.zip(the notarized.apponly), andupdate_appcast.sh:18points the Sparkle enclosure at that same binary zip. The binary enclosure URL does NOT convey source. The §6(d) corresponding-source vehicle is GitHub's auto-generated "Source code (tar.gz)" snapshot at the SAME release tag as the binary.- Make source-equivalence guaranteed, not asserted (the naïve "tag tree == built sources" claim is
FALSE as the repo ships). Verified:
scripts/replace_environment_variables_in_app.shdoes a truncatingcat > config/local.xcconfigwritingCURRENT_PROJECT_VERSION+APPCENTER_SECRETat build time, andconfig/local.xcconfigis.gitignore'd (.gitignore:20) — so the upstream tag tree is missing a build input Xcode consumes, and the §3.1 plan to commitlocal.xcconfigcollides with that truncating write. Close the gap with the §3.1 fixes so the equivalence actually holds: (a) commitconfig/local.xcconfig(un-gitignore per §3.1) so the identity/build config is in the tag tree; (b) make CI append-not-clobber (§3.1 — write onlyCURRENT_PROJECT_VERSION, drop theAPPCENTER_SECRETline) so the build does not overwrite the committed block with an out-of-tree value; (c) state that the ONLY build inputs deliberately NOT in the tag are secrets — the Developer-ID signing cert, the Sparkle EdDSA private key, and the Apple notarization credentials — which GPL does not require to be conveyed. - Therefore the precise claim is: "the tag tree plus the committed build config IS the Corresponding
Source; only signing/notarization secrets differ" — not "the tag snapshot equals the built sources."
Satisfy GPL-3 §6(d)'s "clear directions next to the object code saying where to find the Corresponding
Source" by injecting a source pointer into BOTH §6(d) conveyance channels (GitHub Release AND Sparkle,
since each is an independent object-code delivery path): (a) the Sparkle appcast
<sparkle:releaseNotesLink>/description (rewritten in §3.3) and (b) the GitHub Release body — theextract_latest_changelog.shoutput that feedssoftprops/action-gh-release'sbody(ci_cd.yml:60-65), e.g.Corresponding source: https://github.com/<fork>/archive/refs/tags/v$version.tar.gz. The Sparkle pointer is non-optional: users who receive object code only via auto-update (SUEnableAutomaticChecks,SUScheduledCheckInterval=604800) otherwise get a §6(d) object-code delivery with no source pointer at all. - Conveyance durability — do NOT rely on the transient §6(d) auto-tarball alone (decision required).
One event — a repo/org takedown (the C&D scenario the §5 risk note flags) — triggers THREE coupled
failures at once: (1) the GitHub auto-tarball
…/archive/refs/tags/v$version.tar.gzvanishes, (2) every already-auto-updated client is left pointing at that now-dead source location — a persisting GPL §6 violation after the fact (the obligation outlives the repo), and (3) clients keep pinging a now-dead appcast and silently stop updating. The mitigation must therefore put both the source mirror AND the appcast host on infrastructure NOT tied to the takedown-able GitHub repo/org (a separate host/org, a CDN, or a software-archive), so a takedown degrades the fork to "stale, not retroactively non-compliant" (§0.2) rather than knocking out source, updates, and compliance together. Harden it:- (preferred where practical) Convey source under §6(a)/(b): attach the source tarball as a
release asset alongside the
.app.zip(so the source physically accompanies the object code on the same release), e.g.git archive --format=tar.gz -o <fork>-v$version-src.tar.gz "v$version"added tosoftprops/action-gh-release'sfiles:. This survives even if the auto-tarball endpoint changes, because the source is a first-class asset of the same release. - And/or, if relying on §6(d): commit in the runbook to keeping the source location live for at least as long as any released binary is still offered or auto-updatable (GPL §6(d) requires the source stay available as long as you convey the object code), plus a takedown-contingency mirror (a second host / org / archive) so the pointer survives a repo takedown. Note in §6.2 that a repo rename/move requires re-pointing or preserving the old source URLs the shipped binaries already carry.
- (preferred where practical) Convey source under §6(a)/(b): attach the source tarball as a
release asset alongside the
- Make source-equivalence guaranteed, not asserted (the naïve "tag tree == built sources" claim is
FALSE as the repo ships). Verified:
- Attribution + note changes + prominent notices + in-app legal notices. Retain lwouis's copyright;
state prominently in
README.mdand the in-app About tab that this is a fork oflwouis/alt-tab-macos© Louis Pontoise, GPL-3.0, that the Pro paywall has been removed (all features free), and that it is not affiliated with or endorsed by the original author (§3.2).- GPL §5(a) change-marking — a LIVING document, not a one-time file: the
depaywall:commit prefixes alone are thin. Add a top-levelNOTICE.md(none exists in-tree today;changelog.mdis the auto-generated semantic-release file and is not a substitute) stating: "Forked from lwouis/alt-tab-macos v11.x; removed the Pro paywall. Changed:LicenseManager.computeState(forced.pro),syncLicenseCookie(no-op),AppCenterCrash.init(telemetry off). Date: ." GPL §5(a)'s "carry prominent notices stating that you modified it, and giving a relevant date" goes STALE if the fork's modification surface grows (e.g. aFeedbackWindowrewrite per §3.4, a new edit forced by a chokepoint refactor). Add a §6.2 runbook step: whenever a sync changes the fork's modification surface (not on every routine sync), updateNOTICE.md's change list and date. Thepop--release-notes/changelog is not a substitute — it records versions, not the GPL §5(a) modification statement. - GPL §5(b) — "prominent notices stating that it is released under this License":
README.mdandNOTICE.mdmust state the fork is released under GPL-3.0. - GPL §5(d) — interactive UIs must "display Appropriate Legal Notices" (compliance CHECK, not a
cosmetic repoint): the only in-app legal string today is
App.licence(App.swift:15, sourced fromInfo.plist:21NSHumanReadableCopyright = "GPL-3.0 licence"), surfaced byAboutTab.swift:17. After theApp.swift:16repositoryrepoint (§3.5), verify that AboutTab still renders the GPL notice (AboutTab.swift:17→App.licence) and that the "Source code"HyperlinkLabel(AboutTab.swift:19→App.repository) now points at the FORK's source — that link is also the in-app §6 source pointer (§3.5), so a fork-repoint here is a required compliance step, not cosmetics.
- GPL §5(a) change-marking — a LIVING document, not a one-time file: the
- Trademark / non-confusion. Rebrand name, bundle id, icon, and update feed (§3) so the fork is not
passed off as the original. GPL-3 §7(e) clarifies that trademark rights are NOT conveyed by the
copyright license — so renaming is required under trademark law independent of GPL, not merely
"permitted" by it.
PRODUCT_NAMEis not sufficient — of the 63 totalgrephits for "AltTab" insrc/*.swift(~25 are non-rendering comments), the rendered surface is ~21NSLocalizedStringliterals plus 18 translatableLocalizable.stringsentries (2 of the 20 lines are comments); these render verbatim and ARE the trademark-confusion surface. The §3.2 string sweep is the actual mitigation (mind thesrc/pro/*strings and thePreferencesMigrations.swift:363stored-value KEY that must NOT be renamed — §3.2); without it the "rebrand" premise is false.
Risk note (non-legal-advice): redistributing a de-paywalled build of lwouis's current commercial product invites cease-and-desist / takedown pressure even where GPL-permitted, and residual
AltTabtrademark strings sharpen that lever. Make the C&D posture actionable:
- The §3.2 string sweep + a "not affiliated with / not endorsed by the original author" disclaimer in README, the in-app About tab, AND release notes are the core mitigations.
- The §3.3 Sparkle helper re-sign is also a trademark/origin mitigation — shipping lwouis-signed helpers (TeamID
QXD7GW8FHY) inside a fork-branded app misrepresents the binary's origin; re-signing under the fork's Developer ID removes that.- Be DMCA counter-notice ready (GPL-3 redistribution is a lawful basis), and keep the GPL §6 corresponding-source link (§5(1)) and §5 attribution current so a takedown can be rebutted on the merits.
- Bundle-id consequence (no migration story exists): changing
PRODUCT_BUNDLE_IDENTIFIER(§3.1) makes the fork a separate app — existing AltTab users'UserDefaults/keychain prefs are NOT inherited, and this plan defines no migration. Freeze the fork bundle id once (the keychain-suite invariant, §3.1) and disclose to users that it is a fresh install, not an in-place upgrade of AltTab.The owner should accept this exposure consciously before publishing.
See docs/PLAN-republish-free.md §A for "what you MUST do / MUST NOT do" specifics.
6. Bootstrap checklist + per-sync runbook
6.1 One-time bootstrap
- OWNER SIGN-OFF — conscious go/no-go on redistributing a de-paywalled build (gate everything below this). This is the single highest-level decision in the plan and is a legal/posture call, not a technical one: the fork knowingly redistributes a de-paywalled build of lwouis's current commercial revenue product. GPL-3 permits it (§5), but it invites C&D / takedown pressure (§5 risk note). Before any other bootstrap work, the owner must explicitly accept this exposure and the mitigations (§3.2 brand sweep, the "not affiliated / not endorsed" disclaimer in README + About + release notes, DMCA counter-notice readiness, the §5 off-repo source-mirror + appcast-host durability so a takedown degrades to "stale" not "non-compliant"). Record the decision (and date) — do not let it remain implicit prose.
- Upstream remote:
git remote add upstream https://github.com/lwouis/alt-tab-macos.git && git fetch upstream --tags. - De-paywall patch (3 commits, prefixed
depaywall:): Edit 1LicenseManager.computeState()— wrap body inif false { … }, thenreturn .pro; Edit 2LicenseCookie.syncLicenseCookie— wrap body inif false { … }; Edit 3AppCenterCrash.init— wrap lines 11-20 inif false { … }. Use theif falsewrap, NOT a barereturnabove live code —SWIFT_TREAT_WARNINGS_AS_ERRORS=YES(base.xcconfig:7) makes "code after return" a build error. Build and confirm clean. Keep the literal tokendepaywallin thecomputeState()comment (Guard B greps for it). activatefingerprint neutralization (§2 default — independent ofAPI_DOMAIN): add the 1-linehandleCustomUrlearly-return that drops theactivatehost, so the still-live, scheme-gated<bundleid>://activatehandler (App.swift:474-481) can't POST the machine hardware UUID (RemoteLicenseClient.swift:18→MachineFingerprint.swift:13) anywhere — including to the fork's own backend. Do NOT rely on a non-routableAPI_DOMAINfor this (it silently re-arms if feedback option (a) makesAPI_DOMAINroutable). This is a one-time bootstrap edit, not per-merge.- Feedback decision (§3.4 — now decoupled from
activate): choose theAPI_DOMAINvalue purely on feedback: (a) fork-owned feedback backend (routable — safe now thatactivateis closed at the handler), (b) rewriteFeedbackWindowto open a client-sidegithub.com/<fork>/issues/new?…URL (a real warm-file edit), or (c) hide feedback. A non-routableAPI_DOMAINdisables the feedback feature; pick consciously. RECOMMENDED DEFAULT: (c) hide feedback — it is the lowest recurring merge surface. Option (b) makesFeedbackWindow.swifta warm fork-edited file that conflicts whenever upstream touches it (eroding the merge-clean economics); option (a) is the most ongoing work (a backend to run). Adopt (a)/(b) only if the owner explicitly wants in-app feedback to keep working and accepts the recurring cost. - Identity overlay:
- Un-ignore: remove
/config/local.xcconfigfrom.gitignore:20, then create + commitconfig/local.xcconfig(PRODUCT_NAME, bundle id,DOMAIN,API_DOMAIN,CODE_SIGN_IDENTITY, emptyAPPCENTER_SECRET). APP_NAME=PRODUCT_NAME: setci_cd.yml:19APP_NAMEto the fork name (build/package/appcast scripts depend on it — §3.1).- Replace
README.md(GPL-3 + fork attribution); add the credit line to the About tab. Add a top-levelNOTICE.md(GPL §5(a) change-marking — §5; keep it current per §6.2 when the modification surface grows). package.jsonlicense correction (§5 — resolve the GPL-vs-MIT contradiction): setpackage.json:6"license"from"MIT"to"GPL-3.0"so the machine-readable metadata matchesLICENCE.mdand the in-app GPL notice. Addpackage.jsonto the §6.2 "keep ours" recurring set so a merge cannot re-introduce the MIT string.- Attribution-preserve set (§3.5 — keep verbatim, do NOT sweep/delete): confirm the FOUR binding surfaces are intact — (a)
LICENCE.mdbyte-for-byte; (b)Info.plist:21NSHumanReadableCopyright = "GPL-3.0 licence"(surfaced viaApp.licence/AboutTab.swift:17); (c) the README/About-tab "fork oflwouis/alt-tab-macos, © Louis Pontoise, GPL-3.0" credit; (d)docs/contributors.md+docs/acknowledgments.md(the latter also rendered in-app viaAcknowledgmentsTab.swift). NOTE: AltTab uses NO per-file copyright headers (verified —App.swiftopens withimport Cocoa); there is nothing to "preserve" per-file, so do NOT add a fork-only copyright header to source files (that WOULD risk §8 termination by displacing upstream attribution). The §3.2 brand sweep touches product-name strings ONLY — never author-name attribution / contributor names / "fork of AltTab" (nominative fair use, required by §5). Stripping or displacing any of (a)-(d) breaches the attribution terms and can trigger termination under GPL §8 — which (perLICENCE.md) also provides for cure and reinstatement once the violation is corrected, so the never-strip rule stands on its own merits without relying on cure. .github/FUNDING.yml(§3.5):git rmit OR rewrite to the fork's own sponsorship (currently routesgithub/patreon/ko_fi/PayPal to lwouis). Add to the §6.2 "keep ours" recurring-conflict set.- Support/donation disposition (§3.4): decide whether
<DOMAIN>/support(drivesEndpoints.supportUrl+ the in-app "Support" item) serves a real page or is removed/accepted-as-404 — separate from FUNDING.yml. - Trademark string sweep (§3.2): sweep the rendered "AltTab" surface — ~21
NSLocalizedStringliterals + plain literals (MainMenu.swift:65/76/260, FeedbackWindow.swift:100/239, Menubar, SettingsWindow, AboutTab:153, AppearanceTab:157, PermissionsWindow:36/50, MoveToApplicationsFolder:184, CustomRecorderControl:110, …) — and the 18 translatable entries inresources/l10n/Localizable.strings(20 lines, 2 comments; + every*.lprojcopy); prefer routing throughApp.name. DECIDE on thesrc/pro/*AltTab strings (UpgradeTab:116, ProPromptWindow:12, Day1/Day15/Day21 windows, ProTransitionManager:35, ProConversionCopy:32/39): RECOMMENDED DEFAULT — prove the inert Pro UI is genuinely unreachable oncestate == .pro(the nag windows are nag-scheduled, which never arms; verify no other entry point renders them), and leave thesrc/pro/*strings untouched. Sweeping them is a direct exception to this plan's own "never editsrc/pro" invariant (§2/§5) and re-introduces thesrc/promerge surface the whole strategy avoids — adopt it only if a reachable inert-Pro string is actually found. Do NOT renamePreferencesMigrations.swift:363"Screen showing AltTab"(stored-value migration KEY). Then runscripts/l10n/extract_l10n_strings.shand commit the regeneratedresources/l10n/*.strings(else theensure_generated_files_are_up_to_date.shgate,ci_cd.yml:49, fails). Consider a CI grep gate to keep the sweep durable across merges. - GPL §6 source link: edit
src/App.swift:16repository→ fork's GitHub URL (in-app "Source code" link / Feedback/issues); see §3.5. - Overwrite
resources/icons/app/app.icns+ regenerateapp.iconset/*. Info.plist:62SUPublicEDKeyvalue → fork's EdDSA public key (the<key>is on :61). Do NOT swapInfo.plist:37NSPrincipalClass(value =AppCenterApplication, whichAppsubclasses; swapping breaks the subclass chain — not equivalent to Edit 3 — §3.4).
- Un-ignore: remove
- Sparkle keypair: obtain
generate_keys(NOT vendored —vendor/Sparkle/bin/has onlysign_update; download the Sparkle 2.9.1 tarball perupdate_sparkle.sh:14, orbrew install --cask sparkle, or temporarily un-drop it inupdate_sparkle.shstep 10), run it; public key →Info.plist:62value; private key → CI secretSPARKLE_ED_PRIVATE_KEY. - Re-sign bundled Sparkle helpers (MANDATORY for notarization — §3.3): re-sign
vendor/Sparkle/Helpers/Updater.app+vendor/Sparkle/Helpers/Autoupdateunder the FORK's Developer ID (currently lwouis QXD7GW8FHY), re-commit, and verifycodesign -dvv vendor/Sparkle/Helpers/Updater.appshows the fork's TeamIdentifier. These vendored binaries are whatscripts/copy_sparkle_helpers.shembeds intoSparkle.frameworkat build time (it re-seals the framework with the fork identity but does NOT re-sign the nested helpers — §3.3), so the vendored re-sign is load-bearing. Post-build verification (REQUIRED):codesign -dvvtheSparkle.framework/Versions/A/Updater.appINSIDE the built.app, pluscodesign --verify --deep --stricton the.appand anotarytooldry run. Re-do per Sparkle bump. Add a REQUIRED CI gate that fails the build if any nested Mach-O's TeamIdentifier ≠APPLE_TEAM_ID. - appcast out-of-tree (coupled — §3.3, all three): (a)
git rm appcast.xml+/appcast.xmlin.gitignore+ set up the publish host that serveshttps://<DOMAIN>/appcast.xml(gh-pages + CNAME, or accept agithub.ioDOMAIN); (b) rewritescripts/update_appcast.shto append to the gh-pages feed copy (not in-tree,set -exuwould hard-fail on the removed file), fixing the enclosure URL on line 18 → fork releases (safety-critical) and thereleaseNotesLinkon line 16; (c) remove'appcast.xml'from the@semantic-release/gitassetsarray inrelease.config.js(lines 20-26). - Fork-to-fork auto-update acceptance (§3.6.1 — prove the republish loop reaches EXISTING users): before relying on the loop, install fork release N, publish a throwaway release N+1, and confirm the running app auto-updates N→N+1 cleanly — (1) Sparkle resolves the enclosure to the fork's release (not lwouis's), (2) the EdDSA signature verifies against the embedded fork
SUPublicEDKey, (3)sparkle:versionordering is monotonic over thev100.0.0offset, (4) Gatekeeper is the only friction. A silent failure here strands every existing fork user on a stale build with no signal. This is the upgrade analogue of the feature-unlock check. - Bundle-id / in-place-upgrade decision (§3.1 owner decision): choose (a) FRESH bundle id (default — fresh install, no migration, disclose to users) or (b) reuse an existing id WITH a one-time
proTransition.remembered*→ base-key migration (else paywalled-build users silently lose their Pro selections). Freeze the choice. (Distinct from the §3.6.1 fork-to-fork auto-update above: that is fork-N→fork-N+1 for the fork's own users; this is the AltTab→fork one-time cutover for legacy upstream users.) - Fork version-offset (§4.3 — bake in, don't defer): start the fork's semantic-release line at
v100.0.0(or set afork-vX.Y.ZtagFormat) so a futuregit merge v11.X.0can never collide with a fork tag of the same name. - Developer ID: obtain the fork owner's Developer ID Application cert; freeze bundle id + TeamID (license-keychain invariant). Export p12 → base64 → CI secret.
- Credential lifecycle tracking (§4.6): record expiry dates for the Developer ID cert (~yearly), the Apple Developer Program membership, and the app-specific password; back up
SPARKLE_ED_PRIVATE_KEYdurably (a lost/compromised Sparkle key has no clean rotation — recovery needs a hard-coded migration build). Set a quarterly review reminder. - CI secrets + Environment + permissions (§4.3): create a GitHub Environment named
production(matchesci_cd.yml:23) with no blocking required-reviewers, and attachAPPLE_P12_CERTIFICATE,APPLE_ID,APPLE_PASSWORD,APPLE_TEAM_ID,SPARKLE_ED_PRIVATE_KEYthere. Addpermissions: { contents: write }to the fork'sci_cd.yml(else semantic-release push + gh-release 403). LeaveAPPCENTER_*andWEBSITE_DISPATCH_TOKENunset. - Fork CI: adapt
ci_cd.yml— decide on node: either KEEPnode-version: 16(thepackage.json>=18mismatch only warns undernpm ci, no hard-fail) OR bump to ≥18 as a coordinated toolchain upgrade ofsemantic-release/commitlint/husky (§4.3 — do NOT bump node alone, the pinned v15 semantic-release / v8 commitlint were never validated on node 18/20 and run on the release path); dropupload_symbols_to_appcenter.sh+update_website.sh;softprops/action-gh-release→ fork repo; fixreplace_environment_variables_in_app.shso it preserves the committedlocal.xcconfigidentity block (§3.1 — write onlyCURRENT_PROJECT_VERSION, append, and drop itsAPPCENTER_SECRETline); fixcommitlintviacommitlint.config.jsignores(PRIMARY — §4.3; do NOT use--from HEAD~1 --to HEAD, which is broken over a--no-ffmerge — use--lastif a one-message range is wanted); add thepull_request:trigger +if: github.event_name == 'push'gates on the COMPLETE release-step set includingcommitlintanddetermine_next_version(which misbehave on PR events — §4.2) so the anti-relock guards run pre-merge and the PR check is cleanly green/red. Acceptance test: open a PR that reverts thecomputeState()→.proflip and confirm the PR check goes RED before merge. - Pin the fork runner's Xcode to upstream's value (BOOTSTRAP INVARIANT, not a lever — §4.5): match
ci_cd.yml'sxcode-selectline to upstream's pinnedXcode_26.0.1.scripts/extract_l10n_strings.shregenerates l10n from ALL*.swiftfiles viagenstrings, andensure_generated_files_are_up_to_date.sh(ci_cd.yml:49) hard-fails on any byte-diff (including BOM/encoding differences a differentgenstringsemits). If the fork runner's Xcode drifts from upstream's, that gate reds every PR on a genstrings diff the maintainer didn't cause and can't easily diagnose. Treat the Xcode pin as a monitored invariant; re-pin it on every upstream Xcode bump (§6.2 step 8). - Anti-relock backstop (§4.4): (Guard A) append BOTH mock-injected tests to the EXISTING
src/pro/license/LicenseManagerTests.swift(already in theunit-teststarget — no new file, no pbxproj edit):testDepaywallProNeverLocked(load-bearingstate == .proassert) ANDtestDepaywallStillProAfterTrialExpiry(inject an expiredtrialStartDatesoisProLocked/isProAvailablebecome discriminating — a day-0 trial makes them non-discriminating). Use the existingMockClockfixture (notSystemClock()) so the trial math is deterministic. Do NOT referenceLicenseManager.shared,Endpoints.*, orProFeature.*. (Guard B) add thegrepstep toci_cd.ymlas the FIRST push-job step — beforerun_tests.sh(line 53), so it aborts beforeupdate_appcast.sh(57, Sparkle sign),semantic-release(59, tag push), andaction-gh-release(62); placing it only beforeaction-gh-releaseis too late, since the tag is already pushed by line 59 (§4.4). Grep for thedepaywallmarker + theif false {wrap, NOT a barereturn .pro(which matches the unpatchedLicenseManager.swift:187). Optionally keep a second copy before line 62 as belt-and-suspenders. (Guard C) add the conflict-marker grep at the same first-step position and toguard.yml(a--no-verifyconflict commit must never reach a published tag — §4.4). Also add a SEPARATE fork-owned.github/workflows/guard.yml(on: pull_request) running Guards A+B+C independently ofci_cd.yml, so a carelessci_cd.ymlconflict resolution can't silently strip the PR gate (§4.4). For the fullci_cd.ymlPR signal, thepull_request:trigger must be added (§4.2); the in-ci_cd.ymlrelease-time greps mean a relock/conflicted-tree cannot ship even if a PR gate is missing. Confirmrun_tests.shruns Guard A. - Per-release feature-unlock acceptance check (§4.4 — the consumers aren't unit-testable): establish the short manual/UI checklist confirming each headline Pro feature is FREE in the BUILT app (9th+ shortcut, type-to-search, a Pro appearance style, one more gated feature). This is mandatory on every release (§6.2 step 5), since Guard A structurally cannot reach
ProFeature/PreferenceDefinition/ShortcutAction/TilesView. - Sync workflow: add
.github/workflows/upstream_sync.yml(daily cron +workflow_dispatch, withpermissions: { contents: write, pull-requests: write }, state-file-driven tag selection — sync the oldest upstream tag strictly newer than.fork-sync-state, not "newest tag minus--is-ancestor" (§4.2) — a realchore(sync):merge commit, andgh pr create). - Sync labels (§4.2 — Guard 2 and the §0.1 trip-wire depend on them existing):
gh label create sync,gh label create conflict,gh label create chokepoint-refresh. Make Guard 2 / the trip-wire fail closed — bail + alert if a label query errors or the label is absent; never treat an emptygh pr list --label …result as "no open sync" (an empty list is what a missing label returns). .fork-sync-state(§4.2 — the never-drop-commits invariant): commit a state file recording the last-MERGED upstream tag, advanced ONLY on merge tomaster(a tinyci_cd.ymlpush-job step). The cron syncs the oldest upstream tag strictly newer than it, so a sync PR that is closed-without-merge or abandoned cannot cause its tag to be silently skipped (the state file simply didn't advance). A deferred sync PR is therefore left OPEN, never closed (§6.2).- Cron durability (§4.2 — the in-repo
schedule:auto-disables after 60 days of repo inactivity): primary = an external scheduler hittingrepository_dispatchcarrying theSYNC_BOT_TOKEN(immune to the 60-day auto-disable AND composes with the workflow-re-trigger token fix below); keep the in-reposchedule:+ a heartbeat re-arm as belt-and-suspenders. Add a two-trigger liveness alert (open an issue / notify if (i) nosync/*PR in ~45 days OR (ii) the oldest OPENsync/*PR is older than ~14 days — §4.2). Do not ship the bare in-repo cron as set-and-forget. - Sync-bot credential (§4.2 — BLOCKING): provision
SYNC_BOT_TOKEN— a GitHub App installation token (preferred; least-privilege, short-lived) or a fork-owned PAT — and use it asactions/checkout'stoken:and asGH_TOKENforgh pr createinupstream_sync.yml. Do NOT author the sync push/PR with the defaultGITHUB_TOKENor GitHub's recursion rule meansguard.yml/ci_cd.ymlPR checks never fire and the pre-merge gate silently never runs. Add this token to the §4.6 credential-lifecycle bucket (it expires; can push tomasterif leaked). Acceptance test: open a sync PR through the bot and confirm the guard/CI PR checks actually appear.
6.2 Per-sync runbook (the repeating loop)
- Cron (or
workflow_dispatch) fetches upstream tags, attemptsgit mergeof the oldest upstream tag strictly newer than.fork-sync-state(§4.2 — not "the newest tag") onto async/<tag>branch, and opens a PR labeledsync(clean) orsync,conflict. - If
sync: verify the PR's CI is green — theguard.ymlPR check (Guards A + B + C) is the primary signal; theci_cd.ymlpull_request:path (§4.2) gives the full CI. First confirm the checks ACTUALLY RAN (a bot PR with no checks = theSYNC_BOT_TOKENis mis-set to the defaultGITHUB_TOKENand the gate is disarmed — §4.2). All three guards require thepull_request:trigger / guard workflow + the workflow-re-triggering token added at bootstrap; the release-time Guard B/C greps inci_cd.ymlare the fail-closed backstop if a PR gate is missing. Resolve any identity/CI conflicts with "keep ours" — the recurring set:local.xcconfig,Info.plist,.github/workflows/ci_cd.yml,.github/FUNDING.yml,scripts/update_appcast.sh/update_website.sh,release.config.js,package.json(§5 license field); approve + merge. The pipeline auto-builds → signs → notarizes → publishes the appcast → cuts the GitHub release. (~5–15 min, plus the step-5 acceptance check.) - If a previous sync is still open/blocked (stalled — §4.2): the cron will NOT have opened a new PR
(resolve-in-order, default). Resolve the open
sync/*PR first — do NOT fast-forward to the newest upstream tag and abandon the stranded one (that drops the intervening commits). And do NOT close a deferred sync PR to silence it: a closed-unmerged PR escapes both cron guards, but because.fork-sync-stateonly advances on merge, leaving the PR open is what keeps its tag "next" — a close without a state-file reset is the one move that silently skips a tag (§4.2). Once it merges, the next cron run syncs the next tag; if you must catch up faster, merge the oldest unmerged tag next, not the newest. The liveness alert's stale-open trigger (§4.2) is what surfaces this; a paused backlog compounds the next merge's conflict effort (§4.5). - If
sync,conflict: inspectgit diff --name-only --diff-filter=U.- Identity/CI files only →
git checkout --ours, re-confirm fork URLs/keys, commit, let CI verify. - A chokepoint file (
LicenseManager/ProFeature/PreferenceDefinition/ProTransitionManager) → re-apply the de-paywall intent on top of upstream's new code (theif false { … }wrap); run the anti-relock test locally until green; commit; push; let CI re-verify. (~30–90 min.) Add thechokepoint-refreshlabel to this PR (gh pr edit <n> --add-label chokepoint-refresh) so the §0.1 trip-wire A tally is the querygh pr list --label chokepoint-refresh --search 'merged:>=<12mo>'and never depends on someone remembering. (≥3 such merged PRs in 12 months → reconsider strategy.) - A swept brand-string file (
src/*.swiftliteral orLocalizable.strings) → re-apply the fork name, then re-runscripts/l10n/extract_l10n_strings.shand commit the regenerated*.stringsor theensure_generated_files_are_up_to_date.shgate (ci_cd.yml:49) fails the PR.
- Identity/CI files only →
- Gate-consumer review + feature-unlock acceptance check (MANDATORY every release — the compile + Guard A
provably can't cover the consumers, §2/§4.4): (i) if the sync touched
ProFeature/PreferenceDefinition/ShortcutAction/TilesView(the gate consumers, which upstream churns), confirm the gates still consultstateand that no NEWLicenseStatecase, per-feature entitlement check, or moved state-producer re-arms the paywall; AND (ii) run the §4.4 positive feature-unlock acceptance checklist on the built app (9th shortcut, type-to-search, a Pro appearance style, one more gated feature) so a silent consumer-level relock is caught end-to-end. This is no longer conditional — the unit-test target structurally excludes these consumers, so this manual layer is the only one that exercises them. - Periodic commercial-surface re-audit: if the sync touched
src/pro/,src/api/,src/vendors/, orInfo.plistnetwork keys, re-audit the network/gate/telemetry surface — the 3-edit + 2-test + 1-grep backstop targets v11.0.0's shape and would NOT detect a new gate/endpoint/telemetry path upstream introduces (feeds the §0.1 trip-wire C). - Functional / compatibility-breaking sync (§0.1 trip-wire D — "keep the app working", not just "keep Pro
free"): the Guards + the step-5 feature-unlock check prove Pro is FREE, not that the app still
launches on the supported macOS range. If this sync touches
MACOSX_DEPLOYMENT_TARGET(config/base.xcconfig:5) /LSMinimumSystemVersion, anyInfo.plistpermission/entitlement key,PreferencesMigrations.swift, or bumps a major upstream version, then before merging: (i) smoke/launch test the built app on the supported macOS floor (confirm it launches, the switcher works, permissions prompt correctly); (ii) review the README/system-requirements and update them if the floor moved; (iii) for a major version bump, an explicit owner go/no-go (confirm thev100.0.0offset still orders correctly and that no user-facing behavior change needs a release-note callout). Agit diff --name-only <last-synced-tag>..<new-tag> -- config/base.xcconfig Info.plist src/preferences/PreferencesMigrations.swiftsurfaces this branch deterministically. - NOTICE.md (GPL §5(a)) + attribution-preserve check: (i) if this sync changed the fork's
modification surface (a new edit, a
FeedbackWindowrewrite, a chokepoint-refresh edit), updateNOTICE.md's change list + date (§5); (ii) confirm the FOUR-surface attribution-preserve set survived the merge — (a)LICENCE.md, (b)Info.plist:21NSHumanReadableCopyrightGPL string, (c) the README/About fork credit, (d)docs/contributors.md+docs/acknowledgments.mdare still present and intact, and the §3.2 brand sweep did not over-sweep the "fork of AltTab / © Louis Pontoise" attribution or contributor names (§3.5). (Do NOT check for "source copyright headers" — there are none; that earlier check verified nothing.) A merge that adds upstream contributors should keep them. - Guard durability + Xcode-pin checks (cheap, periodic — not every sync): confirm the
pull_request:trigger still exists inci_cd.ymlandguard.ymlis intact (an upstreamci_cd.ymlconflict resolved "keep theirs" can strip the trigger — §4.4); watch the 60-day cron auto-disable — if nosync/*PR has appeared in ~45 days, the cron may be disabled, re-arm it (§4.2); and whenever a sync bumps upstream's pinned Xcode (ci_cd.ymlxcode-selectline, currentlyXcode_26.0.1), re-pin the fork runner to match (§4.5 / §6.1 invariant) before the next l10n regeneration, orensure_generated_filesreds every PR on a genstrings byte-diff the maintainer didn't cause. - Merge to
mastertriggersci_cd.yml→ release. After the release publishes, run the §3.6.1 fork-to-fork auto-update check (install the prior release, confirm it auto-updates to this one cleanly — see §6.1 bootstrap and §3.6.1). Done until the next upstream tag. - Rollback runbook — a shipped release regressed (relock slipped a guard, a feature broke, a dead source
link, a bad enclosure URL): (i) stop the bleed — delete or mark the bad GitHub Release as a
pre-release/draft so the auto-tarball and download stop being offered; (ii) un-offer it via Sparkle —
remove the bad
<item>from the published appcast feed (the out-of-tree gh-pages copy, §3.3) so clients that have not yet pulled it stop seeing it; clients that already auto-updated need a new higher version to recover, so (iii) roll forward, don't roll back — cut a fixedvN+1(Sparkle only moves forward via monotonicsparkle:version; you cannot push a lower version to already-updated clients, §3.6.1). (iv) If the regression was a relock that the guards missed, treat it as a guard gap: add a behavioral assertion before the next release. Note Sparkle has no remote kill-switch — already-updated users stay on the bad build untilvN+1reaches them on their next scheduled check (SUScheduledCheckInterval=604800, i.e. up to a week), so a fastvN+1is the only real remediation.
Appendix — cross-references
docs/AUDIT.md— Master Paywall Audit. §3 "The paywall, anatomized" (the chokepoints); §4 "The complete set of Pro-gated features"; §5 "The complete removal surface" (the deletion surface — note the audit recommends full excision; this plan deliberately diverges to leave-src/pro-inert for the tracked-fork goal, reusing the audit's churn/isolation data but not its conclusion); §6 "Notable findings, risks, open questions".docs/PLAN-republish-free.md— the one-shot full-excision plan that this document supersedes for the tracked-fork goal. §A Licensing & Ethics (the GPL-3 source for §5 here); §B Removal strategies; §C-D excision execution + re-identity; §E risks/testing.docs/GRAPHS.md— dependency/call graphs of the paywall subsystem.docs/audit/01-core-architecture.md— App lifecycle, theApp.swiftspine and its license fan-out.docs/audit/03-preferences-and-gating.md—PreferenceDefinition.read()gate + degradable prefs.docs/audit/04-license-subsystem.md—LicenseManager,computeState(),RemoteLicenseClient,LicenseCookie,MachineFingerprint(the primary chokepoint + network side-effects).docs/audit/05-trial-nag-scheduling.md—ProTransitionManager(nag scheduler; inert via the flip).docs/audit/06-profeature-and-copy.md—ProFeature.attemptUse()(hard-gated features).docs/audit/07-pro-ui-and-menubar.md— upsell UI (Menubar / SettingsWindow / Upgrade tab).docs/audit/08-build-release-distribution.md—ci_cd.yml, scripts, Sparkle/appcast, signing.docs/audit/09-telemetry-and-upstream.md— AppCenter telemetry, endpoints, upstream-tracking notes.