2017 lines
180 KiB
Markdown
2017 lines
180 KiB
Markdown
# 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](https://github.com/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`](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
|
||
two `if false { … }` body wraps — the `if false` form 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 .pro` hard-breaks the build only on a **signature/enum refactor** of `LicenseState`/`computeState`.
|
||
A **silent semantic relock** that still compiles — a new `LicenseState` case, the gate re-pointed off
|
||
`state`, a server-driven entitlement check, the live-state producer moved out of `computeState()` — is
|
||
caught NOT by the compiler and NOT by Guard A (the gate-consumers `ProFeature`/`PreferenceDefinition` are
|
||
outside the `unit-tests` target), 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 so `project.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-membered* `LicenseManagerTests.swift`, so it too needs
|
||
**zero** pbxproj edits — see §4.4. The only `project.pbxproj` touch 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 to `project.pbxproj` plus surgery on the
|
||
251-commit `App.swift` spine — 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 replaced `README.md` and `app.icns`, a few dormant `Info.plist` lines) 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-repo `schedule:` 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-injected `LicenseManager`,
|
||
appended to the existing `LicenseManagerTests.swift`, using the existing `MockClock`), a target-independent
|
||
CI grep (Guard B), and a conflict-marker grep (Guard C), run by a SEPARATE fork-owned `guard.yml` workflow on
|
||
`pull_request` so they survive even a careless `ci_cd.yml` conflict 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 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`** — 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
|
||
before `action-gh-release` is 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
|
||
default `GITHUB_TOKEN`** — by GitHub's recursion rule a `GITHUB_TOKEN`-authored PR fires NO `pull_request`
|
||
checks, so `guard.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's `ci_cd.yml` AND gate every release-side step (including `commitlint` and
|
||
`determine_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's
|
||
`ci_cd.yml` fires 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 `AltTab` brand strings (l10n + a handful of `src/*.swift` literals), repoint
|
||
`App.repository`/`APP_NAME`, and wire `permissions: contents: write` + a `production` Environment.
|
||
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 `genstrings` re-run** whenever a sync touches one of the ~18
|
||
trademark-swept `src/*.swift` files or the `.strings` files (a *baseline* recurrence, not a rare edge case
|
||
— `ensure_generated_files_are_up_to_date.sh` hard-fails on any uncommitted regenerated diff, §4.5); and only
|
||
a rare upstream refactor of `src/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.yml` apparatus 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-paywall `v10.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`](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.pbxproj` is the single most merge-hostile file in the repo**:
|
||
240 commits all-time, 10 since v11.0.0, and ~99 lines that reference `pro/`, `License`, `ProFeature`,
|
||
or `PreferenceDefinition`. Deleting `src/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.swift` is the wiring spine** (5 commits since v11.0.0; historically the hottest source
|
||
file). Excision requires severing the license fan-out (≈448–460), the `onAction`/`onAppLaunchComplete`
|
||
hooks (≈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 PR
|
||
`chokepoint-refresh` (in addition to `sync,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 have `guard.yml`/the sync workflow `gh pr edit --add-label chokepoint-refresh` when
|
||
that count > 0 (or when `git diff --name-only --diff-filter=U` shows 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/renames `src/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`, currently `10.13`; drives
|
||
`LSMinimumSystemVersion` via `Info.plist:32-33` and Sparkle's `minimumSystemVersion`) — a raised macOS
|
||
floor changes who can run/auto-update the fork;
|
||
- **`Info.plist` permission/entitlement keys** (a new `NS*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.0` fork 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:
|
||
1. **Fall back to excision** — execute `docs/PLAN-republish-free.md` (full `src/pro/` deletion) once, accept
|
||
the one-time cost, and thereafter track upstream against the excised tree; OR
|
||
2. **Pin to pre-paywall `v10.12.0`** (commit `317a485b`, the last tag with no paywall — see `docs/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.swift`
|
||
> and 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 from `App.swift:~460`.
|
||
- `refreshState()` → `computeState()` (line 100), called by Menubar/SettingsWindow before reading `state`.
|
||
- The only network-driven reassignment (`revalidateWithServer`, line 223) also calls `computeState()` —
|
||
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`:** `isProLocked` alone leaves `isProAvailable` reading
|
||
from `state` (still `.trialExpired`), so `ProFeature.attemptUse()` line 75
|
||
(`if LicenseManager.shared.isProAvailable { return true }`) would *not* short-circuit; you'd also have
|
||
to patch `attemptUse` and `LicenseState.isProAvailable`. Forcing `state` is strictly smaller (1 edit
|
||
vs. 2-3).
|
||
- **vs. a `#if FREE_BUILD` compilation flag:** that adds a `SWIFT_ACTIVE_COMPILATION_CONDITIONS` line in
|
||
an identity-bearing xcconfig plus `#if` blocks — 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-optional
|
||
`LicenseState` enum. If lwouis renames the enum, splits it, or changes the signature, the forced
|
||
`return .pro` stops 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 NEW `LicenseState` case, `isProAvailable`/`isProLocked` being
|
||
> rewritten to consult something other than `state`, a per-feature or server-driven entitlement check added
|
||
> alongside the gate, or the live-state producer being **moved** out of `computeState()`. 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 to `LicenseManager`-level behavior (`ProFeature` /
|
||
> `PreferenceDefinition` / the gate consumers are NOT in the `unit-tests` target). The backstop for this class
|
||
> of relock is therefore the §6.2 **manual-review step**: on any sync that touches
|
||
> `ProFeature`/`PreferenceDefinition`/`ShortcutAction`/`TilesView` (the gate consumers, which upstream
|
||
> demonstrably churns — `60cca89b`/`3059dc23`), the reviewer MUST confirm the gates still consult `state`.
|
||
|
||
**Do NOT individually edit `isProLocked`, `ProFeature.attemptUse`, or `PreferenceDefinition.read`** —
|
||
they become inert automatically once `state == .pro`:
|
||
- `isProLocked` → `false` (line 69, the `.pro` case).
|
||
- `isProAvailable` → `true` → `attemptUse()` returns `true` at line 75 before consulting `ProTransitionManager`.
|
||
- `PreferenceDefinition.read()` → the `guard … isProLocked` at 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:7` sets
|
||
> `SWIFT_TREAT_WARNINGS_AS_ERRORS = YES`. A `return` placed *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 in `if false { … }`** (so it is syntactically reachable, not flagged)
|
||
> and put `return .pro` after 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:7` sets
|
||
> `SWIFT_TREAT_WARNINGS_AS_ERRORS = YES`, a bare `return .pro` placed *above* the existing body emits
|
||
> "code after 'return' will never be executed" → a build error. The illustrative-only naïve form is:
|
||
>
|
||
> ```swift
|
||
> // 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):
|
||
>
|
||
> ```swift
|
||
> 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 `return` above the body trips "code after 'return'". Use the
|
||
> `if false { … }` wrap (or `#if false` around the body) so the diff is a single safe hunk:
|
||
>
|
||
> ```swift
|
||
> 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 `activate` handler itself, independent of where `API_DOMAIN` points.**
|
||
> Add a 1-line early return in `handleCustomUrl` that drops/ignores the `activate` host (or returns before
|
||
> the `LicenseManager.shared.activate(...)` call). This kills the fingerprint POST **regardless of the
|
||
> `API_DOMAIN` value**, so it is robust even when feedback (§3.4) needs `API_DOMAIN` to be a *routable*
|
||
> fork-owned host. This is a *4th* `src/App.swift` edit — but `App.swift` line 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 the `API_DOMAIN` repoint it does NOT depend on the feedback decision. **This plan adopts the handler
|
||
> neutralization as the default for the `activate` surface.**
|
||
>
|
||
> **An `API_DOMAIN` repoint alone does NOT make `activate` safe.** Pointing `API_DOMAIN` at 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) requires `API_DOMAIN` to be **routable and fork-owned**, and in that branch a crafted `activate`
|
||
> deep 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
|
||
> the `API_DOMAIN` value is a *feedback* decision (§3.4), and the `activate` fingerprint POST is closed
|
||
> separately by the handler edit above — **the two are now decoupled.**
|
||
|
||
> **WARNING — `API_DOMAIN` is shared with the feedback feature.** A *non-routable* `API_DOMAIN` also breaks
|
||
> `FeedbackWindow` (its `feedbackUrl` derives from `API_DOMAIN`), disabling a user-visible feature. Because
|
||
> the `activate` surface is now closed by the handler edit (above) regardless of `API_DOMAIN`, you are free to
|
||
> make `API_DOMAIN` *routable* for feedback (§3.4 option a) WITHOUT re-arming the fingerprint POST. Choose the
|
||
> `API_DOMAIN` value purely on the feedback decision (§3.4); do not pick "non-routable" believing it is what
|
||
> protects `activate` — 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.start` lines 11-20 follow). Wrap them so
|
||
> the diff is a single safe hunk:
|
||
>
|
||
> ```swift
|
||
> 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.sh` and `build_app.sh` build **`-configuration
|
||
Release`**, so `#if DEBUG` code does not ship — including `QAMenu`'s manual mock-Pro / nag pokes and
|
||
`mockProUser()` (`#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:133` hardcodes the upstream bundle id — this is **test-only, out of scope** for
|
||
the shipped build; called out so a reader cross-checking `AUDIT.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.sh` does
|
||
> `cat > config/local.xcconfig` (a truncating **overwrite**) writing BOTH `CURRENT_PROJECT_VERSION` AND
|
||
> `APPCENTER_SECRET = $APPCENTER_SECRET` — which would clobber the committed identity block on every CI run.
|
||
> **A naïve `cat >` → `cat >>` (append) is NOT enough:** the committed identity block above ALSO sets
|
||
> `APPCENTER_SECRET =`, so an append yields **two `APPCENTER_SECRET` lines**. (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_VERSION` and append it** — drop its
|
||
> `APPCENTER_SECRET` line entirely (telemetry is off, so the secret is unused). `CURRENT_PROJECT_VERSION`
|
||
> is not in the committed block, so a single appended line is collision-free. OR
|
||
> - **Write `CURRENT_PROJECT_VERSION` into a separate `version.xcconfig`** that `local.xcconfig` `#include`s,
|
||
> 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 in `proTransition.remembered*` keys (in the `.license` UserDefaults suite),
|
||
> and `read()` returning the *stored* (downgraded) value silently keeps the free equivalent. Ship a
|
||
> one-shot migration that, before first read, copies each `proTransition.remembered<Key>` index back to its
|
||
> base key and clears the `proTransition.*` keys. (This is the same migration `AUDIT.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.xcconfig` alone is NOT enough — `ci_cd.yml` hardcodes `APP_NAME` (verified):** `ci_cd.yml:19`
|
||
> sets `APP_NAME: AltTab`, and `build_app.sh` checks `$APP_NAME.app/Contents/MacOS/$APP_NAME` while
|
||
> `package_and_notarize_release.sh` / `scripts/update_appcast.sh:8` zip/reference `$APP_NAME-$version.zip`.
|
||
> The built bundle name comes from `PRODUCT_NAME` (xcconfig). So setting `PRODUCT_NAME=<ForkName>` but
|
||
> leaving `APP_NAME=AltTab` makes `build_app.sh`'s existence check fail and the appcast/zip names mismatch
|
||
> the artifact. **`ci_cd.yml` is fork-owned (§3.5) — set its `APP_NAME` env equal to `PRODUCT_NAME`.**
|
||
> `PRODUCT_NAME` also does *not* touch the hardcoded `"AltTab"` UI strings — see the §3.2 string sweep.
|
||
|
||
> **Rejected — Option B (new `config/fork.xcconfig` + an `#include` appended to `base.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 of `base.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:16` sets
|
||
`AssociatedBundleIdentifiers = App.bundleIdentifier` precisely 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 at `LoginItem.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; only
|
||
`Info.plist:18 CFBundleIconFile = app.icns` drives it), referenced in pbxproj by relative `path`.
|
||
**Overwrite the bytes of `app.icns` and regenerate `app.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_NAME`
|
||
only 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-note `comment:` strings that **do not render**; the actually-rendered surface is **~21
|
||
`NSLocalizedString` literals containing "AltTab"** plus the l10n template. In
|
||
`resources/l10n/Localizable.strings` there are **20 `AltTab` lines, of which 2 are `/* … */` comments → 18
|
||
translatable entries** (replicated per `*.lproj`). A build with `PRODUCT_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/pro` `src/*.swift` literals to route through `App.name`** (verified examples):
|
||
`Menubar.swift:43` (`About %@` — already uses `App.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.name`
|
||
form already exists, prefer it so future renames are free (`App.name` is `App.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 edit `src/pro`
|
||
— so either (a) sweep these *despite* that rule (a deliberate, documented exception, accepting the
|
||
`src/pro` merge surface), or (b) prove the inert-Pro UI is genuinely unreachable once `state == .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:363` uses
|
||
`"Screen showing AltTab"` as an OLD STORED preference-value KEY in a `migratePreferenceValue` map —
|
||
renaming it breaks migration of existing users' `screensToShow` pref. (Note `MacroPreferences.swift:187`
|
||
`.showingAltTab → NSLocalizedString("Screen showing AltTab")` is a *display* string and may be reworded,
|
||
but the migration KEY at `PreferencesMigrations.swift:363` must stay literal.) Also leave `App.swift` log
|
||
strings and any non-UI literals alone.
|
||
- `resources/l10n/Localizable.strings` (and every `*.lproj` copy): the 18 translatable `AltTab` entries
|
||
(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
|
||
swept `NSLocalizedString` literals, so the sweep doesn't silently regress on a future merge.
|
||
- **CI gate consequence:** editing `src/*.swift` literals and the `.strings` files re-triggers
|
||
`scripts/ensure_generated_files_are_up_to_date.sh` (regenerates l10n via `genstrings`; §4). Run
|
||
`scripts/l10n/extract_l10n_strings.sh` and commit the regenerated `resources/l10n/*.strings` **before**
|
||
pushing, or CI fails on the diff. The fork runner's Xcode/`genstrings` must match upstream's to keep
|
||
this gate green.
|
||
- **Attribution (GPL-3 §5/§7 + trademark):** keep `LICENCE.md` byte-for-byte (never edit). Replace
|
||
`README.md` with the fork's README, which **must** state: *"<ForkName> is a fork of
|
||
[lwouis/alt-tab-macos](https://github.com/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.swift` renders a
|
||
`Website` hyperlink to `Endpoints.website`, which auto-repoints via the cascade).
|
||
|
||
### 3.3 Sparkle (auto-update)
|
||
|
||
- **EdDSA keypair:** generate a fresh one. **NOTE (verified): `vendor/Sparkle/bin/generate_keys` does NOT
|
||
exist** — `vendor/Sparkle/bin/` ships only `sign_update`, because `vendor/scripts/update_sparkle.sh` step 10
|
||
(line 191) deliberately drops `generate_keys`/`generate_appcast`/`BinaryDelta`. Obtain `generate_keys` by
|
||
one of: (a) download the Sparkle 2.9.1 release tarball — the same `RELEASE_URL`
|
||
(`https://github.com/sparkle-project/Sparkle/releases/download/2.9.1/Sparkle-2.9.1.tar.xz`)
|
||
`update_sparkle.sh:14` uses — and run its bundled `bin/generate_keys`; (b) `brew install --cask sparkle`
|
||
and use its `generate_keys`; or (c) temporarily un-drop `generate_keys` in `update_sparkle.sh` step 10.
|
||
`SUPublicEDKey` is a literal `<string>` value (not a `$(VAR)`): the `<key>` is at `Info.plist:61`, the value
|
||
at `:62`. **Write the public key to the value on `Info.plist:62`** (not the `<key>` on :61) — cold,
|
||
merge-safe. Keep the **private** key in CI secrets (`SPARKLE_ED_PRIVATE_KEY`, consumed by
|
||
`scripts/update_appcast.sh`).
|
||
- **Re-sign the bundled Sparkle helpers (one-time, MANDATORY for notarization):** the embedded
|
||
`vendor/Sparkle/Helpers/Updater.app` and `vendor/Sparkle/Helpers/Autoupdate` are currently signed
|
||
`Developer ID Application: Louis Pontoise (QXD7GW8FHY)` / `TeamIdentifier=QXD7GW8FHY` (verified with
|
||
`codesign -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 `.app` and
|
||
these lwouis-signed helpers causes notarization rejection or a Gatekeeper launch failure for end users
|
||
(the `disable-library-validation` entitlement can mask it locally while notarization still rejects).
|
||
**Action:** before first release, re-sign `Updater.app` + `Autoupdate` under the FORK's Developer ID
|
||
(e.g. run `vendor/scripts/update_sparkle.sh` with the fork cert in the keychain / set the helper signing
|
||
identity), re-commit them, and verify `codesign -dvv vendor/Sparkle/Helpers/Updater.app` shows 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 copies `vendor/Sparkle/Helpers/{Updater.app,
|
||
Autoupdate}` into `Sparkle.framework/Versions/A/` inside the built `.app` and **re-seals
|
||
`Sparkle.framework`** with the build's `CODE_SIGN_IDENTITY` (so `local.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 nested `Updater.app`/`Autoupdate` — those keep their **vendor-time** signature. So the
|
||
bootstrap re-sign of `vendor/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/Gatekeeper `TeamIdentifier` mismatch warned about above, with the copy site being
|
||
`copy_sparkle_helpers.sh`.
|
||
- **Post-build verification (REQUIRED):** after `build_app.sh`, run `codesign -dvv` on the
|
||
`Sparkle.framework/Versions/A/Updater.app` **inside the built `.app`** (not just the vendored source
|
||
copy) and confirm the fork's `TeamIdentifier`. Promote the parenthetical CI "TeamIdentifier-drift" check
|
||
(§6.1 / below) to a **REQUIRED** gate that **fails the build** if any nested Mach-O's `TeamIdentifier` ≠
|
||
`APPLE_TEAM_ID`.
|
||
- **Feed URL:** `Endpoints.appcastUrl = "\(website)/appcast.xml"` (`Endpoints.swift:7`, where
|
||
`website = "https://\(domain)"`) — repoints via the `DOMAIN` cascade automatically; no code edit.
|
||
**Precondition:** the fork must own a web host that actually serves `https://<DOMAIN>/appcast.xml` (or
|
||
accept a `github.io` value for `DOMAIN` and CNAME a gh-pages branch). The Sparkle feed URL is fixed by
|
||
the `DOMAIN` cascade, so a bare release-asset URL won't match unless `<DOMAIN>` serves `/appcast.xml`.
|
||
- **`appcast.xml` — the conflict trap (verified tracked):** `appcast.xml` is 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:
|
||
1. **`git rm appcast.xml`** + add `/appcast.xml` to `.gitignore`, and publish the feed out-of-tree
|
||
(gh-pages branch or a release asset).
|
||
2. **Rewrite `scripts/update_appcast.sh`.** As shipped it edits an in-tree `appcast.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, append `ITEM.txt` there, 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 the **`releaseNotesLink` on line 16**
|
||
(`https://alt-tab.app/changelog-bare` → a fork URL or drop it).
|
||
3. **Remove `'appcast.xml'` from the `@semantic-release/git` `assets` array in `release.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.js` is 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>://activate` POST** — closed by the §2 **`handleCustomUrl`
|
||
neutralization** (drop the `activate` host), which kills the hardware-UUID POST *regardless of where
|
||
`API_DOMAIN` points.* Do **NOT** rely on a non-routable `API_DOMAIN` for 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 makes `API_DOMAIN` routable (the crafted `activate` link then POSTs the fingerprint to the *fork's
|
||
own* backend). **A routable `API_DOMAIN` alone does NOT make `activate` safe — the handler edit does.**
|
||
- **`feedbackUrl` / FeedbackWindow** — `API_DOMAIN` drives this; choose its value purely on the feedback
|
||
decision below. Because `activate` is already neutralized at the handler, you may now make `API_DOMAIN`
|
||
*routable* (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_DOMAIN` that accepts the `{title, body, kind,
|
||
debugProfile}` POST and files/forwards issues. Keeps the feature; most work.
|
||
- **(b) Rewrite `FeedbackWindow.submit`/`prepareRequest`** to build a prefilled
|
||
`https://github.com/<fork>/issues/new?title=…&body=…` URL **client-side** and `NSWorkspace.shared.open`
|
||
it (dropping the JSON POST + `debugProfile` attach). **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 of `FeedbackWindow.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 §2
|
||
`handleCustomUrl` neutralization, independent of `API_DOMAIN` — the two decisions are decoupled.
|
||
|
||
`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/vendors` physically 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-3 `init` suppression + upstream's
|
||
> `networkRequestsAllowed = 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 swap `NSPrincipalClass` (`Info.plist` key:36/value:37).** The principal class is
|
||
**`AppCenterApplication`** (verified — `Info.plist:37` value, a vendored `NSApplication` subclass), and the
|
||
app's own `App` class *subclasses* it (`App.swift:7`); swapping to `NSApplication` would unload
|
||
`AppCenterApplication` and break the `App` subclass chain + bridging-header coupling, not just AppCenter.
|
||
Edit 3's `init`-suppress already achieves telemetry-off without that risk; they are not equivalent. Drop the
|
||
fork's CI use of `upload_symbols_to_appcenter.sh` and `update_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`, empty `APPCENTER_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 PayPal `hosted_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:** either `git 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 alongside `ci_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` (`SUPublicEDKey` **value**; 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 by
|
||
`AboutTab.swift:19` (the in-app "Source code" link) and `FeedbackWindow.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-line
|
||
`src/App.swift` divergence (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 relocate `repository` to a fork-owned file to
|
||
zero out even that. Decide whether `FeedbackWindow`'s `/issues` should follow the same repoint.
|
||
- Optionally `FeedbackWindow.swift` `feedbackUrl` (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 accessors `static let licence =
|
||
> Bundle.main.object(forInfoDictionaryKey: "NSHumanReadableCopyright")` (line 15) and `static let repository`
|
||
> (line 16) — `App.swift` begins with `import Cocoa`, no copyright block — and `src/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 "© <Fork> " 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:21` `NSHumanReadableCopyright = "GPL-3.0 licence"`** (verified — `<key>` at :20, value at
|
||
:21), surfaced at runtime via `App.licence` (`App.swift:15`) and rendered by `AboutTab.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** — "<ForkName> 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) and **`docs/acknowledgments.md`**
|
||
(third-party library credits) — both exist in-tree today and the deferred §A baseline explicitly says they
|
||
MUST be retained. `docs/acknowledgments.md` is **also rendered in-app** (`AcknowledgmentsTab.swift` loads
|
||
`acknowledgments.md` from the bundle, surfaced via `MacroPreferences`'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-macos` repo 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-eager `grep -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 `.zip` there via
|
||
`softprops/action-gh-release` — §4.0). Link `https://github.com/<fork>/releases/latest` from 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).
|
||
- **(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):
|
||
1. **Enclosure URL resolves to the FORK's release** — `update_appcast.sh:18` enclosure points at
|
||
`github.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."
|
||
2. **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.
|
||
3. **`SUVersion`/version ordering is monotonic** — the fork's `v100.0.0` offset (§4.3) must keep
|
||
`sparkle:version` strictly increasing across fork releases so Sparkle offers N+1 over N (and an upstream
|
||
`v11.x`/`v12.x` numeric never out-sorts a fork version).
|
||
4. **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 URL
|
||
`github.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:**
|
||
```bash
|
||
git remote add upstream https://github.com/lwouis/alt-tab-macos.git
|
||
git fetch upstream --tags
|
||
```
|
||
|
||
**Per-release sync (steady state):**
|
||
```bash
|
||
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`:
|
||
|
||
```yaml
|
||
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 original `git merge --no-commit --no-ff` then handing a dirty tree to
|
||
`create-pull-request` would let that action author ONE squashed, **non-conventional** commit, discarding
|
||
upstream's commits and the merge commit. `commitlint` lints the whole pushed range (`ci_cd.yml:48`,
|
||
`--from "$GITHUB_EVENT_BEFORE" --to "$GITHUB_EVENT_AFTER"`) and `@semantic-release/commit-analyzer`
|
||
needs conventional commits, so that squashed/non-conforming commit would fail the release job or yield a
|
||
wrong bump. Authoring a real `chore(sync):` merge commit and pushing it ourselves avoids this.
|
||
- **Upstream-only tag selection + the `merge-base --is-ancestor` guard.** Enumerate tags from the
|
||
**upstream remote** (`git ls-remote --tags upstream`), NOT `git tag -l 'v*'` — the latter also matches the
|
||
fork's OWN release tags (the fork cuts `vX.Y.Z` every sync, §4.3), so "newest tag" could be a fork tag.
|
||
Without the ancestor guard the newest tag is selected unconditionally, so after merging `v11.4.0` the cron
|
||
re-creates `sync/v11.4.0` and re-opens an identical PR every day until `v11.5.0` ships — training the
|
||
maintainer to ignore the review gate.
|
||
- **Explicit `permissions:` + `gh pr create`.** GitHub defaults the workflow `GITHUB_TOKEN` to read-only;
|
||
push and PR creation need `contents: write` + `pull-requests: write`. **But `permissions:` 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 default `GITHUB_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
|
||
> ambient `GITHUB_TOKEN` does NOT trigger other workflow runs* (no `push`/`pull_request` events fire from
|
||
> it). So if `upstream_sync.yml` pushes `sync/*` and runs `gh pr create` with `${{ github.token }}`, then
|
||
> **`guard.yml` and `ci_cd.yml`'s `pull_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 in `ci_cd.yml`, which runs **post-merge** — but, wired as the FIRST push-job
|
||
> step (§4.4), it still aborts the job **before** `update_appcast.sh`/`semantic-release` push 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 the `actions/checkout` `token:` (so the push
|
||
> re-triggers) and as `GH_TOKEN` for `gh pr create`.
|
||
> - **(acceptable) A fork-owned classic/fine-grained PAT** with `contents`+`pull-requests` write, stored as
|
||
> `SYNC_BOT_TOKEN`. Simpler but it expires and becomes a new silent SPOF (see §4.6), and if leaked it can
|
||
> push to `master` — prefer the App token.
|
||
> - **(alternative) Self-author the push with the default token, then explicitly re-dispatch** the guard
|
||
> checks via `workflow_dispatch`/`repository_dispatch` carrying 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.yml`
|
||
> AND `ci_cd.yml` `pull_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 `master` **nor** 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 the `sync`/`conflict`/
|
||
> `chokepoint-refresh` labels, but `gh pr list --label sync` returns 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 to `master` (a tiny `ci_cd.yml` push-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-state` is *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 a `schedule:` 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 enable` re-arm call — so the 60-day clock never expires.
|
||
> (A heartbeat job is itself a `schedule:` 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
|
||
> hitting `repository_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 the `SYNC_BOT_TOKEN` fix 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-repo `schedule:` + 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.yml` is triggered
|
||
> `on: push: branches: [master]` ONLY — there is **no `pull_request:` trigger** and `ci_cd.yml` is 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 to `master` — 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
|
||
> required `pull_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.**
|
||
|
||
```yaml
|
||
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) and `determine_next_version` (:50) sit *in front of* `run_tests`
|
||
> (:53) and misbehave on PR events. **Either gate them under `if: 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.3 `ignores`
|
||
> predicate) or `npx 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.yml` has **no `permissions:` key**. `@semantic-release/git` pushes commits+tags to `master` and
|
||
`softprops/action-gh-release@v3` (line 62) creates the release (`release.config.js` has no
|
||
`@semantic-release/github` plugin, so softprops is the sole release-creator). Both need
|
||
`contents: write`. GitHub defaults the workflow `GITHUB_TOKEN` to 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. **Add `permissions: { contents: write }` to
|
||
the fork's `ci_cd.yml`** (or set the repo default workflow token to read-write).
|
||
- `ci_cd.yml:23` sets `environment: production`. **Create a GitHub Environment named `production`** in the
|
||
fork and attach `APPLE_P12_CERTIFICATE` / `APPLE_ID` / `APPLE_PASSWORD` / `APPLE_TEAM_ID` /
|
||
`SPARKLE_ED_PRIVATE_KEY` there (matching the `environment:`), 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: 16`** and 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 commitlint `ignores` fix), 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.sh` and
|
||
`package_and_notarize_release.sh` consume 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 the `SUPublicEDKey`
|
||
value on `Info.plist:62` (`<key>` is on :61). Mandatory: Sparkle verifies the appcast against the
|
||
embedded public key.
|
||
- `APPCENTER_SECRET`/`APPCENTER_TOKEN` — leave unset; drop `upload_symbols_to_appcenter.sh`.
|
||
- `WEBSITE_DISPATCH_TOKEN` — drop with the `update_website.sh` step.
|
||
|
||
**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.0`** and semantic-releases from there, so a fork tag and an upstream
|
||
> `v11.x`/`v12.x` tag can never share a name for the foreseeable future. (Alternative if a distinct prefix is
|
||
> preferred: configure semantic-release's `tagFormat` to a `fork-vX.Y.Z` namespace; the offset is simpler
|
||
> and keeps Sparkle's numeric `sparkle:version` ordering monotonic.) Set this once and the sync loop can
|
||
> never deadlock on a namespace clash. A trailing `+fork` build-metadata suffix is NOT sufficient on its own
|
||
> — SemVer ignores build metadata for precedence and `git tag` names still collide on the `vX.Y.Z` stem. (2) `commitlint` lints 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/or
|
||
`commit-analyzer` mis-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:
|
||
|
||
```js
|
||
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-ff` merge, `HEAD~1` resolves to
|
||
> the FIRST parent (the fork tip), and `git rev-list HEAD~1..HEAD` enumerates 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 same `from..to` range, 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
|
||
> `AntiRelockTests` that (1) references `ProFeature.*.attemptUse()` won't even **link**: `ProFeature.swift`
|
||
> is in the **app** target's Sources phase (`project.pbxproj:1970`), NOT the `unit-tests` phase
|
||
> (`BF0C80F5`, lines 1829-1900) — that phase has `LicenseManager.swift`, `LicenseState.swift`,
|
||
> `Endpoints.swift`, `RemoteLicenseClient.swift` but **no `ProFeature.swift`**. (2) Asserting on
|
||
> `LicenseManager.shared` fails even on a CORRECTLY patched build: `.shared` is never `initialize()`d in
|
||
> tests (the only caller is `App.swift:460`), so its `state` stays at the `.trialExpired` default
|
||
> (`LicenseManager.swift:51`) → `isProLocked == true`. The patch flips `computeState()`, which the test
|
||
> never invokes. (3) `LicenseManager.shared`'s lazy init eagerly builds
|
||
> `RemoteLicenseClient(baseUrl: Endpoints.licenseApiBaseUrl)`, and `Endpoints.swift:4-5` force-unwraps
|
||
> `Bundle.main.object(forInfoDictionaryKey: "Domain"/"ApiDomain") as! String`. The `unit-tests` target has
|
||
> `GENERATE_INFOPLIST_FILE = YES` and no `TEST_HOST`/`BUNDLE_LOADER` (`project.pbxproj:2473,2477`) → a
|
||
> standalone xctest bundle whose auto-generated Info.plist has no `Domain` key → **`fatalError` / crash**.
|
||
> (4) `!Endpoints.licenseApiBaseUrl.contains("alt-tab.app")` is trivially true once §3.1 repoints
|
||
> `API_DOMAIN` (and the base value is `alt-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:
|
||
|
||
```swift
|
||
// 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 .pro` grep matches the UNPATCHED file.** `computeState()` already
|
||
> contains `return .pro` at `LicenseManager.swift:187` (the keychain-valid branch) on a fully *relocked*
|
||
> tree, so `grep -Eq 'return[[:space:]]+\.pro'` passes even when the paywall is armed — it is **not**
|
||
> load-bearing. The **only** load-bearing grep is the `depaywall` marker. Make the forced-return check
|
||
> co-locate with the marker (e.g. require the marker line *and* an `if false` guard in the same file) so it
|
||
> actually distinguishes patched from unpatched:
|
||
>
|
||
```yaml
|
||
- 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 .pro` grep (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")**:
|
||
|
||
```yaml
|
||
- 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:
|
||
|
||
1. **`.github/workflows/guard.yml` (fork-owned, `on: pull_request`)** — runs Guard A (`run_tests.sh` or a
|
||
focused `xcodebuild test`) + Guard B grep + Guard C conflict-marker grep. This is a SEPARATE workflow that does **not** inherit
|
||
`ci_cd.yml`'s trigger, so an upstream `ci_cd.yml` conflict resolved "keep ours/theirs" that accidentally
|
||
strips the `pull_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.sh` is
|
||
> `xcodebuild test` — it only runs on a macOS runner with the §4.5/§6.1-pinned Xcode (`Xcode_26.0.1`). If
|
||
> `guard.yml` is left on the cheaper `ubuntu-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 on
|
||
> `ubuntu-latest` (near-instant) as one job, and Guard A (`xcodebuild test`) on `macos-15` as a separate
|
||
> job. **Bootstrap assertion:** on the deliberately-relocked acceptance PR (§4.2), confirm `guard.yml`
|
||
> actually executed a *build+test* on `macos-15` (not just the greps) — i.e. Guard A produced a real test
|
||
> result, red on the relock.
|
||
2. **`ci_cd.yml` `pull_request:` path (§4.2)** — the same guards, in the fork's main workflow, for the
|
||
complete CI signal on the PR.
|
||
3. **`ci_cd.yml` push path, Guard B + Guard C greps wired in as the FIRST job steps — before `run_tests.sh`
|
||
(line 53), and therefore unconditionally before `update_appcast.sh` (line 57, Sparkle `sign_update` +
|
||
appcast `ITEM` append), `npx semantic-release` (line 59, which pushes the `vX.Y.Z` tag to `master`), and
|
||
`softprops/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.yml` ordering — this corrects an earlier draft).**
|
||
> An earlier version of this plan placed Guard B/C "immediately before `softprops/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
|
||
> Sparkle `sign_update` and appends the signed `ITEM` to the appcast feed) → `npx semantic-release` (59,
|
||
> which via `@semantic-release/git` commits and **pushes the `vX.Y.Z` tag to `master`**) → `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 of `master` and **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
|
||
> before `action-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 the `pull_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.yml` runs
|
||
> `xcodebuild test` on **`macos-15`** (§4.4 layer 1), and the `ci_cd.yml` PR 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 on `ubuntu-latest` so 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_CERTIFICATE` secret in the `production`
|
||
Environment, and **re-sign + re-notarize** — note this **intersects the §3.3 Sparkle helper re-sign**: the
|
||
bundled `vendor/Sparkle/Helpers/*` must be re-signed under the *new* cert too, or the nested-Mach-O
|
||
`TeamIdentifier` check 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:** store `SPARKLE_ED_PRIVATE_KEY` durably (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, or `package_and_notarize_release.sh` fails 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 default `GITHUB_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 to `master`). **Rotation runbook (a botched swap silently
|
||
degrades to the default `GITHUB_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_TOKEN` secret, 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 to `GITHUB_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.yml` PR 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).
|
||
|
||
**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_TOKEN` expiring (§4.2), the in-repo `schedule:` 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),
|
||
> commitlint `ignores` going 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 **live** `guard.yml`/`ci_cd.yml` checks
|
||
> 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's `ci_cd.yml` value. 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.md` is verbatim GNU GPL v3, but `package.json:6` declares `"license": "MIT"` **for the application
|
||
> itself** (name `alt-tab-macos`, not a dependency) — a conflicting, machine-readable, public license artifact
|
||
> (most plausibly a stale `semantic-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.md` **plus** the in-app
|
||
> `NSHumanReadableCopyright = "GPL-3.0 licence"` notice (`Info.plist:21`, surfaced via `App.licence`) govern
|
||
> the *software*; the `package.json` `license` field 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.json` `license` to `"GPL-3.0"` to match
|
||
> the actual grant, and add `package.json` to 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.0`** the 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:
|
||
|
||
1. **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.md` **byte-for-byte** (never edit). **§6
|
||
corresponding-source mechanic (corrected):** the release asset is **binary-only** — `ci_cd.yml:66`
|
||
attaches `files: ${{ env.XCODE_BUILD_PATH }}/*.zip` (the notarized `.app` only), and
|
||
`update_appcast.sh:18` points 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.sh` does a
|
||
truncating `cat > config/local.xcconfig` writing `CURRENT_PROJECT_VERSION` + `APPCENTER_SECRET` at build
|
||
time, and `config/local.xcconfig` is `.gitignore`'d (`.gitignore:20`) — so the upstream tag tree is
|
||
*missing* a build input Xcode consumes, and the §3.1 plan to *commit* `local.xcconfig` collides with that
|
||
truncating write. Close the gap with the §3.1 fixes so the equivalence actually holds:
|
||
(a) **commit `config/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 only `CURRENT_PROJECT_VERSION`, drop the
|
||
`APPCENTER_SECRET` line) 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 — the
|
||
`extract_latest_changelog.sh` output that feeds `softprops/action-gh-release`'s `body` (`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.gz` vanishes, (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 to `softprops/action-gh-release`'s `files:`. 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.
|
||
2. **Attribution + note changes + prominent notices + in-app legal notices.** Retain lwouis's copyright;
|
||
state prominently in `README.md` and the in-app About tab that this is a fork of `lwouis/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-level `NOTICE.md` (none exists in-tree today; `changelog.md` is 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: <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. a `FeedbackWindow` rewrite 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),
|
||
update `NOTICE.md`'s change list and date. The `pop--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.md` and
|
||
`NOTICE.md` must 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 from
|
||
`Info.plist:21` `NSHumanReadableCopyright = "GPL-3.0 licence"`), surfaced by `AboutTab.swift:17`. After
|
||
the `App.swift:16` `repository` repoint (§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.
|
||
3. **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_NAME` is not sufficient** — of the **63 total `grep` hits** for "AltTab"
|
||
in `src/*.swift` (~25 are non-rendering comments), the rendered surface is **~21 `NSLocalizedString`
|
||
literals** plus **18 translatable `Localizable.strings` entries** (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 the `src/pro/*` strings and the `PreferencesMigrations.swift:363` stored-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 `AltTab`
|
||
> trademark 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 1 `LicenseManager.computeState()` — wrap body in `if false { … }`, then `return .pro`; Edit 2 `LicenseCookie.syncLicenseCookie` — wrap body in `if false { … }`; Edit 3 `AppCenterCrash.init` — wrap lines 11-20 in `if false { … }`. **Use the `if false` wrap, NOT a bare `return` above 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 token `depaywall` in the `computeState()` comment (Guard B greps for it).
|
||
- [ ] **`activate` fingerprint neutralization (§2 default — independent of `API_DOMAIN`):** add the 1-line `handleCustomUrl` early-return that drops the `activate` host, so the still-live, scheme-gated `<bundleid>://activate` handler (`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-routable `API_DOMAIN` for this** (it silently re-arms if feedback option (a) makes `API_DOMAIN` routable). This is a one-time bootstrap edit, not per-merge.
|
||
- [ ] **Feedback decision (§3.4 — now decoupled from `activate`):** choose the `API_DOMAIN` value purely on feedback: (a) fork-owned feedback backend (routable — safe now that `activate` is closed at the handler), (b) rewrite `FeedbackWindow` to open a client-side `github.com/<fork>/issues/new?…` URL (a real warm-file edit), or (c) hide feedback. A non-routable `API_DOMAIN` disables the feedback feature; pick consciously. **RECOMMENDED DEFAULT: (c) hide feedback** — it is the lowest *recurring* merge surface. Option (b) makes `FeedbackWindow.swift` a 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.xcconfig` from `.gitignore:20`, then create + commit `config/local.xcconfig` (`PRODUCT_NAME`, bundle id, `DOMAIN`, `API_DOMAIN`, `CODE_SIGN_IDENTITY`, empty `APPCENTER_SECRET`).
|
||
- [ ] **`APP_NAME` = `PRODUCT_NAME`:** set `ci_cd.yml:19` `APP_NAME` to 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-level `NOTICE.md` (GPL §5(a) change-marking — §5; keep it current per §6.2 when the modification surface grows).
|
||
- [ ] **`package.json` license correction (§5 — resolve the GPL-vs-MIT contradiction):** set `package.json:6` `"license"` from `"MIT"` to `"GPL-3.0"` so the machine-readable metadata matches `LICENCE.md` and the in-app GPL notice. Add `package.json` to 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.md` byte-for-byte; (b) `Info.plist:21` `NSHumanReadableCopyright = "GPL-3.0 licence"` (surfaced via `App.licence`/`AboutTab.swift:17`); (c) the README/About-tab "fork of `lwouis/alt-tab-macos`, © Louis Pontoise, GPL-3.0" credit; (d) `docs/contributors.md` + `docs/acknowledgments.md` (the latter also rendered in-app via `AcknowledgmentsTab.swift`). **NOTE: AltTab uses NO per-file copyright headers (verified — `App.swift` opens with `import 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 (per `LICENCE.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 rm` it OR rewrite to the fork's own sponsorship (currently routes `github`/`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` (drives `Endpoints.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 `NSLocalizedString`
|
||
literals** + 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** in
|
||
`resources/l10n/Localizable.strings` (20 lines, 2 comments; + every `*.lproj` copy); prefer routing
|
||
through `App.name`. **DECIDE on the `src/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 once `state == .pro`** (the nag windows are
|
||
nag-scheduled, which never arms; verify no other entry point renders them), and leave the `src/pro/*`
|
||
strings untouched. Sweeping them is a **direct exception to this plan's own "never edit `src/pro`"
|
||
invariant** (§2/§5) and re-introduces the `src/pro` merge surface the whole strategy avoids — adopt it
|
||
only if a reachable inert-Pro string is actually found. **Do NOT rename `PreferencesMigrations.swift:363`
|
||
`"Screen showing AltTab"` (stored-value migration KEY).** Then
|
||
run `scripts/l10n/extract_l10n_strings.sh` and commit the regenerated `resources/l10n/*.strings` (else
|
||
the `ensure_generated_files_are_up_to_date.sh` gate, `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:16` `repository` → fork's GitHub URL (in-app "Source code" link / Feedback `/issues`); see §3.5.
|
||
- [ ] Overwrite `resources/icons/app/app.icns` + regenerate `app.iconset/*`.
|
||
- [ ] `Info.plist:62` `SUPublicEDKey` **value** → fork's EdDSA public key (the `<key>` is on :61). **Do NOT** swap `Info.plist:37` `NSPrincipalClass` (value = `AppCenterApplication`, which `App` subclasses; swapping breaks the subclass chain — not equivalent to Edit 3 — §3.4).
|
||
- [ ] **Sparkle keypair:** obtain `generate_keys` (NOT vendored — `vendor/Sparkle/bin/` has only `sign_update`; download the Sparkle 2.9.1 tarball per `update_sparkle.sh:14`, or `brew install --cask sparkle`, or temporarily un-drop it in `update_sparkle.sh` step 10), run it; public key → `Info.plist:62` value; private key → CI secret `SPARKLE_ED_PRIVATE_KEY`.
|
||
- [ ] **Re-sign bundled Sparkle helpers (MANDATORY for notarization — §3.3):** re-sign `vendor/Sparkle/Helpers/Updater.app` + `vendor/Sparkle/Helpers/Autoupdate` under the FORK's Developer ID (currently lwouis QXD7GW8FHY), re-commit, and verify `codesign -dvv vendor/Sparkle/Helpers/Updater.app` shows the fork's TeamIdentifier. **These vendored binaries are what `scripts/copy_sparkle_helpers.sh` embeds into `Sparkle.framework` at 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 -dvv` the `Sparkle.framework/Versions/A/Updater.app` INSIDE the built `.app`, plus `codesign --verify --deep --strict` on the `.app` and a `notarytool` dry 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.xml` in `.gitignore` + set up the publish host that serves `https://<DOMAIN>/appcast.xml` (gh-pages + CNAME, or accept a `github.io` `DOMAIN`); (b) rewrite `scripts/update_appcast.sh` to append to the gh-pages feed copy (not in-tree, `set -exu` would hard-fail on the removed file), fixing the enclosure URL on **line 18** → fork releases (safety-critical) and the `releaseNotesLink` on **line 16**; (c) remove `'appcast.xml'` from the `@semantic-release/git` `assets` array in `release.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:version` ordering is monotonic over the `v100.0.0` offset, (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 a `fork-vX.Y.Z` `tagFormat`) so a future `git merge v11.X.0` can 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_KEY` durably (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`** (matches `ci_cd.yml:23`) with no blocking required-reviewers, and attach `APPLE_P12_CERTIFICATE`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`, `SPARKLE_ED_PRIVATE_KEY` there. Add `permissions: { contents: write }` to the fork's `ci_cd.yml` (else semantic-release push + gh-release 403). Leave `APPCENTER_*` and `WEBSITE_DISPATCH_TOKEN` unset.
|
||
- [ ] **Fork CI:** adapt `ci_cd.yml` — **decide on node:** either KEEP `node-version: 16` (the `package.json` `>=18` mismatch only *warns* under `npm ci`, no hard-fail) OR bump to ≥18 *as a coordinated toolchain upgrade* of `semantic-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); drop `upload_symbols_to_appcenter.sh` + `update_website.sh`; `softprops/action-gh-release` → fork repo; fix `replace_environment_variables_in_app.sh` so it preserves the committed `local.xcconfig` identity block (§3.1 — write only `CURRENT_PROJECT_VERSION`, append, and drop its `APPCENTER_SECRET` line); fix `commitlint` via `commitlint.config.js` `ignores` (PRIMARY — §4.3; do NOT use `--from HEAD~1 --to HEAD`, which is broken over a `--no-ff` merge — use `--last` if a one-message range is wanted); add the `pull_request:` trigger + `if: github.event_name == 'push'` gates on the COMPLETE release-step set including `commitlint` and `determine_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 the `computeState()→.pro` flip 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`'s `xcode-select` line to upstream's pinned `Xcode_26.0.1`. `scripts/extract_l10n_strings.sh` regenerates l10n from ALL `*.swift` files via `genstrings`, and `ensure_generated_files_are_up_to_date.sh` (`ci_cd.yml:49`) **hard-fails on any byte-diff** (including BOM/encoding differences a different `genstrings` emits). 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 the `unit-tests` target — **no new file, no pbxproj edit**): `testDepaywallProNeverLocked` (load-bearing `state == .pro` assert) AND `testDepaywallStillProAfterTrialExpiry` (inject an expired `trialStartDate` so `isProLocked`/`isProAvailable` become discriminating — a day-0 trial makes them non-discriminating). **Use the existing `MockClock` fixture (not `SystemClock()`)** so the trial math is deterministic. Do NOT reference `LicenseManager.shared`, `Endpoints.*`, or `ProFeature.*`. (Guard B) add the `grep` step to `ci_cd.yml` **as the FIRST push-job step — before `run_tests.sh` (line 53), so it aborts before `update_appcast.sh` (57, Sparkle sign), `semantic-release` (59, tag push), and `action-gh-release` (62)**; placing it only before `action-gh-release` is too late, since the tag is already pushed by line 59 (§4.4). Grep for the **`depaywall` marker** + the **`if false {` wrap**, NOT a bare `return .pro` (which matches the unpatched `LicenseManager.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 to `guard.yml` (a `--no-verify` conflict 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 of `ci_cd.yml`, so a careless `ci_cd.yml` conflict resolution can't silently strip the PR gate (§4.4). For the full `ci_cd.yml` PR signal, the `pull_request:` trigger must be added (§4.2); the in-`ci_cd.yml` release-time greps mean a relock/conflicted-tree cannot ship even if a PR gate is missing. Confirm `run_tests.sh` runs 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`, with `permissions: { 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 real `chore(sync):` merge commit, and `gh 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 empty `gh 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 to `master` (a tiny `ci_cd.yml` push-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 hitting `repository_dispatch` carrying the `SYNC_BOT_TOKEN`** (immune to the 60-day auto-disable AND composes with the workflow-re-trigger token fix below); keep the in-repo `schedule:` + a heartbeat re-arm as belt-and-suspenders. **Add a two-trigger liveness alert** (open an issue / notify if (i) no `sync/*` PR in ~45 days OR (ii) the oldest OPEN `sync/*` 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 as `actions/checkout`'s `token:` and as `GH_TOKEN` for `gh pr create` in `upstream_sync.yml`. **Do NOT author the sync push/PR with the default `GITHUB_TOKEN`** or GitHub's recursion rule means `guard.yml`/`ci_cd.yml` PR 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 to `master` if 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)
|
||
|
||
1. Cron (or `workflow_dispatch`) fetches upstream tags, attempts `git merge` of the **oldest upstream tag
|
||
strictly newer than `.fork-sync-state`** (§4.2 — not "the newest tag") onto a `sync/<tag>` branch, and
|
||
opens a PR labeled `sync` (clean) or `sync,conflict`.
|
||
2. **If `sync`:** verify the PR's CI is green — the `guard.yml` PR check (Guards A + B + C) is the primary
|
||
signal; the `ci_cd.yml` `pull_request:` path (§4.2) gives the full CI. **First confirm the checks
|
||
ACTUALLY RAN** (a bot PR with no checks = the `SYNC_BOT_TOKEN` is mis-set to the default `GITHUB_TOKEN`
|
||
and the gate is disarmed — §4.2). **All three guards require the `pull_request:` trigger / guard workflow +
|
||
the workflow-re-triggering token added at bootstrap; the release-time Guard B/C greps in `ci_cd.yml` are
|
||
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.)
|
||
3. **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-state` only 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).
|
||
4. **If `sync,conflict`:** inspect `git 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 (the `if false { … }` wrap); run the
|
||
anti-relock test locally until green; commit; push; let CI re-verify. (~30–90 min.) **Add the
|
||
`chokepoint-refresh` label to this PR** (`gh pr edit <n> --add-label chokepoint-refresh`) so the §0.1
|
||
trip-wire A tally is the query `gh 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/*.swift` literal or `Localizable.strings`) → re-apply the fork name,
|
||
then **re-run `scripts/l10n/extract_l10n_strings.sh` and commit the regenerated `*.strings`** or the
|
||
`ensure_generated_files_are_up_to_date.sh` gate (`ci_cd.yml:49`) fails the PR.
|
||
5. **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 consult `state`** and that no NEW `LicenseState` case, 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.
|
||
6. **Periodic commercial-surface re-audit:** if the sync touched `src/pro/`, `src/api/`, `src/vendors/`, or
|
||
`Info.plist` network 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).
|
||
7. **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`, any `Info.plist` permission/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 the `v100.0.0` offset still
|
||
orders correctly and that no user-facing behavior change needs a release-note callout). A
|
||
`git diff --name-only <last-synced-tag>..<new-tag> -- config/base.xcconfig Info.plist src/preferences/PreferencesMigrations.swift` surfaces this branch deterministically.
|
||
8. **NOTICE.md (GPL §5(a)) + attribution-preserve check:** (i) if this sync changed the fork's
|
||
*modification surface* (a new edit, a `FeedbackWindow` rewrite, a chokepoint-refresh edit), update
|
||
`NOTICE.md`'s change list + date (§5); (ii) **confirm the FOUR-surface attribution-preserve set survived
|
||
the merge** — (a) `LICENCE.md`, (b) `Info.plist:21` `NSHumanReadableCopyright` GPL string, (c) the
|
||
README/About fork credit, (d) `docs/contributors.md` + `docs/acknowledgments.md` are 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.
|
||
9. **Guard durability + Xcode-pin checks (cheap, periodic — not every sync):** confirm the `pull_request:`
|
||
trigger still exists in `ci_cd.yml` and `guard.yml` is intact (an upstream `ci_cd.yml` conflict resolved
|
||
"keep theirs" can strip the trigger — §4.4); watch the **60-day cron auto-disable** — if no `sync/*` 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.yml` `xcode-select` line, currently `Xcode_26.0.1`), re-pin the fork
|
||
runner to match** (§4.5 / §6.1 invariant) before the next l10n regeneration, or `ensure_generated_files`
|
||
reds every PR on a genstrings byte-diff the maintainer didn't cause.
|
||
10. Merge to `master` triggers `ci_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.
|
||
11. **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 fixed `vN+1` (Sparkle only moves
|
||
forward via monotonic `sparkle: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 until `vN+1` reaches them on their next scheduled check
|
||
(`SUScheduledCheckInterval=604800`, i.e. up to a week), so a fast `vN+1` is 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, the `App.swift` spine 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.
|