Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new 10.6 updater and legacy106 guard introduce confirmed failure modes (including an insecure update path) that should be corrected before shipping.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds a Snow Leopard (macOS 10.6) deployment floor alongside the existing 10.9 builds, including cross-build tooling, compatibility gates, packaging, and a 10.6-native updater + dedicated appcast to support legacy systems.
Changes:
- Introduces a 10.6 “cross-legacy” CMake preset and floor-aware build logic (toolchain wrapper, SDK106 sysroot assembly, GOAMD64=v1, legacy shim rooting).
- Adds a new 10.6-native Objective-C updater app and a separate 10.6 appcast artifact in the release pipeline.
- Extends CI/release workflows to build, gate, and publish 10.6-targeted binaries and artifacts.
File summaries
| File | Description |
|---|---|
| updater/updater106.m | New minimal 10.6-safe updater implementation reading a dedicated appcast and installing a .pkg. |
| tests/assert_legacy106_compatible.sh | New compatibility gate for 10.6 floor binaries (arch/min-os/symbol shims/GOAMD64 popcnt audit). |
| patches/tailscaled.go.patch | Roots package stamping and legacy106 shim import for daemon + CLI via upstream patches. |
| patches/systray.go.patch | Adds legacy106 shim import and updater handoff logic (plus peers menu additions) via upstream patch. |
| patches/systray-psn-classic-macos.patch | Strips -psn_* args before flag parsing for classic macOS LaunchServices behavior. |
| patches/peercred-legacy-kernels.patch | Treats darwin EOPNOTSUPP/ENOTSUP from peercred as “not implemented” for 10.6 kernels; roots legacy106 shims. |
| overlays/legacy106/stubs.c | C stubs for libSystem symbols missing on 10.6 (e.g., strnlen/dirfd/pthread_main_thread_np). |
| overlays/legacy106/legacy106.go | darwin_10_6-tagged cgo shims for missing libSystem/Security/ARC runtime symbols; roots via init. |
| overlays/legacy106/legacy106_off.go | Non-10.6 placeholder so blank imports compile on other floors. |
| overlays/legacy106/crt106.S | Minimal _start CRT object for 10.6 dyld startup requirements. |
| CMakePresets.json | Adds cross-legacy preset and wires build/test presets. |
| cmake/tailscale_go.cmake | Adds floor selection + build-dir floor marker; broadens build dependencies via globs/manifest; floor-specific tests. |
| cmake/package_pkg_legacy106.sh | New 10.6-focused packaging script producing a min-10.6 .pkg and optionally bundling the 10.6 updater. |
| cmake/make_app.sh | Adds configurable LSMinimumSystemVersion for systray app bundle. |
| cmake/legacy106/prepare_sdk106.sh | Builds/caches an SDK106 sysroot (SDK109 + crt1.10.6.o) with concurrency-safe locking. |
| cmake/legacy106/mavericks-cross-clang-106 | New CC wrapper for 10.6 min version + legacy symbol archive linkage. |
| cmake/build_updater106.sh | New build script for the 10.6 updater app bundle. |
| cmake/build_tailscale.sh | Adds floor argument, GOAMD64=v1 and legacy106 build flow, plus SDK106 stubs archive build. |
| .gitignore | Ignores build output dir for build-cross-legacy/. |
| .github/workflows/release.yml | Adds SDK106 caching, builds/gates 10.6 binaries + updater, packages and publishes 10.6 .pkg + appcast. |
| .github/workflows/ci.yml | Adds build-legacy106 job to cross-build and gate 10.6-floor outputs on macos-26. |
Review details
- Files reviewed: 20/22 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Adds a complete 10.6 deployment floor to the ModernMavericks tailscale build system, producing min-10.6 x86_64 binaries that run natively on Core 2 CPUs and join current tailscale.com tailnets. Build system - MAVERICKS_TAILSCALE_FLOOR CMake cache variable (10.9 default, 10.6 via the cross-legacy preset) with per-floor build-dir markers - cmake/legacy106/mavericks-cross-clang-106: CC wrapper twin of the toolchain's, with min-10.6 target and our crt1.10.6.o (10.6 dyld requires a classic _start; modern toolchains emit LC_MAIN only) - crt106.S: handcrafted startup object (no Apple bytes; assembled from committed source at build time by prepare_sdk106.sh) - GOAMD64=v1: Core 2 lacks POPCNT/SSE4.2; Go 1.26's default baseline emits them (SIGILL). Under v1 the compiler inline-multi-versions math/bits per function behind a runtime.x86HasPOPCNT check - Input-keyed SDK106 cache with PID-based lock (atomic mv claim, never evicts, fail-only timeout, trap-before-PID) - Build graph: globs with CONFIGURE_DEPENDS + LIST_DIRECTORIES false, content-conditional manifest stamp, floor markers reject stale dirs Patches (applied to both floors; darwin_10_6-tagged where floor-specific) - peercred: 10.6 kernel lacks LOCAL_PEERPID (EOPNOTSUPP errno 102); restricted to that errno pair, grants rw on nil creds (pre-peercred semantics; any other error still propagates) - systray: Peers menu (online-first, click copies IP); Check for updates gated on updater existence, probes 10.6 updater first; -psn_* argv stripping (classic macOS LaunchServices passes Carbon PSN args that flag.Parse exits on) - tailscaled: hostinfo package stamp; legacy106 blank import for the CLI (whose import graph never reaches ipn/ipnauth) Overlays - legacy106.go (darwin_10_6-tagged): arc4random_buf, SecTransform-era Security constants, six objc ARC-runtime entries (objc_release etc. via objc_msgSend — 10.6 libobjc predates them); volatile-store rooting via l106_root() with __attribute__((used)) - legacy106_off.go (!darwin_10_6): empty placeholder for other floors - stubs.c: pthread_main_thread_np (constructor-cached), strnlen, dirfd (errno=EBADF on nil) Gate (tests/assert_legacy106_compatible.sh) - Exactly-x86_64 arch check (rejects fat binaries via 'are:' marker) - Fail-closed: nm, otool, lipo all captured with explicit status checks - Per-function POPCNT guard audit (x86HasPOPCNT reference in function body; runtime-internal sites bounded by hardware evidence) Updater (updater/updater106.m + cmake/build_updater106.sh) - Minimal ObjC updater (~300 lines, MRC, no external framework) - Replaces Sparkle 1.27.3 (its binary declares min-10.9; dyld refuses) - Binary-safe fetch via curl -o file (MacPorts curl for TLS 1.2; stock 10.6 curl lacks it — fails with actionable install message) - Appcast parser iterates ALL items, selects the LAST with minSysVersion <= 10.6 (separate appcast-10.6.xml feed) - mkdtemp secure temp dir; cleanup at every exit via cleanupAndExit() - Admin install via osascript; single-quote path escaping - Feed URL build-configurable, derived from git remote Packaging (cmake/package_pkg_legacy106.sh) - Hard install floor 10.6 (vs 10.9.5); no --host-arch (10.6 Installer rejects hostArchitectures) - Ships our 10.6 updater + daily update-check LaunchAgent - Postinstall: strips exit 0, loads agent via launchctl asuser, or cleans old 10.9 updater leftovers on no-updater installs - Preinstall: shebang-first composition (from original script) CI + release - build-legacy106 job: cross-legacy preset + legacy106 gate + updater build with arch/min-version assertions; distinct cache key - release.yml: builds both floors, produces two pkgs + separate appcast-10.6.xml (not mixed into Sparkle feed — unsigned items confuse 10.9 clients) Known limitations (documented, deferred) - Ed25519 signature verification not implemented (requires ObjC Ed25519 without external libraries on 10.6) - CLI tailscaled on macOS cannot use exit nodes (NetworkExtension requires 10.15+; empirically confirmed on hardware) - Mullvad exit nodes not distributed to CLI nodes (control-plane decision; system extension clients only) - CI Go installation duplicated between jobs (composite action refactor tracked for follow-up) Verified on hardware: 10.6.8 Core 2 P8700 (macmini), joined to tailscale.com tailnet, WireGuard tunnel DERP-to-direct, menu-bar app launches from Finder, daemon + CLI + systray all pass the legacy106 gate. 23 review rounds (Copilot + cubic), 163 findings addressed. ci(release): generate notes via shipyard's release-notes.sh Delete the hand-rolled notes pipeline: the kind-dependent stub (upstream vs. repackage bullet prose), the direct upstream-notes.sh / previous-release-tag.sh / ingredient-pins.sh / ingredient-notes.sh calls, and the KIND wiring that fed them. One release-notes.sh call now writes dist/RELEASE_NOTES.md directly, which sign_and_appcast.sh's --notes-file reads (same file, same bytes, no more release-notes/$VER.md-then-copy). The per-date upstream-notes hook (build/upstream-release-notes-url.sh) is untouched; the generator calls it via upstream-notes.sh. The manual-tag pre-committed-notes gate is untouched. Add release-notes/README.md documenting the (now accurate) convention: an optional per-version prose file, never starting with its own "## " title. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Zns48xhomhdoijbAuLEXv chore(deps): update dependency mavericks-golang to v1.26.8-mavericks.3 (ModernMavericks#15) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> chore(deps): update dependency mavericks-golang to v1.26.8-mavericks.2 (ModernMavericks#14) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ci(release): scan the logs of a run that fails after signing, too The scan job ran only when the build succeeded, so a run that failed AFTER signing -- public logs written while the key was in use -- was never scanned (swift-runtime's first org-key bridge did exactly that). It now runs under always() plus the same publish condition; shipyard's scan-for-key.yml (cf5f982) scans the logs alone when the build never produced its artifact. Publishing still needs a successful build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrAjvVtJ5DprLeCgNNoTpe feat(updater): trust the ModernMavericks org signing key The family is moving to one shared Sparkle key. The updater now ships the org key's public half (/XQ+fNmP...) as SUPublicEDKey in place of tailscale's own (KXf+Nc7e...). This is a BRIDGE release, and the order matters: 1. Release this signed with tailscale's CURRENT key -- the repo-level SPARKLE_PRIVATE_KEY, which installed updaters trust. shipyard's assert_update_trusted.sh checks exactly that and says the next release must be signed with the new key. 2. Only after it publishes, delete the repo-level SPARKLE_PRIVATE_KEY, so the org-level secret (the private half of /XQ+fNmP...) signs from then on. Deleting it first would sign this release with a key no installed updater trusts, and the trust check would refuse it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrAjvVtJ5DprLeCgNNoTpe ci(release): prove each signing run's logs don't carry the key A `scan` job between package and publish calls shipyard's scan-for-key.yml, which fetches this run's finished job logs and scans them, and the files about to be released, for any piece of SPARKLE_PRIVATE_KEY -- raw, hex, or base64 at any alignment -- and fails before anything is published. publish-release.yml looks for its record on every signed release; without it, it warns (and, once every signing product has this job, fails). The job needs actions: read to read the run's logs, which is why it is ours and not a step of publish-release.yml. The signing step's "is it set?" test was `[ -n "$SPARKLE_PRIVATE_KEY" ]`, which prints the key under any shell trace. It is now `printenv SPARKLE_PRIVATE_KEY | grep -q .`, which never expands it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrAjvVtJ5DprLeCgNNoTpe chore(deps): update dependency tailscale-source to v1.102.4 (ModernMavericks#13) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> feat(notes): link the new upstream's entry in tailscale's changelog A -mavericks.1 exists to ship a new upstream Tailscale, and its notes named the version without saying where to read what changed. Now they end with an "### Upstream" link, via shipyard's upstream-notes.sh -- which asks this repo where upstream's notes are, and only for a NEW upstream (repackages get none). build/upstream-release-notes-url.sh answers it. Tailscale's GitHub releases just say "see the changelog", and that page is anchored by DATE, not version, so the script finds the client entry titled exactly "Tailscale v<version>" and links its anchor (1.102.3 -> #2026-08-19-client). A release with no entry (1.102.0 had none), or an unreachable page, links the client changelog and says why on stderr; notes never fail a release. Tested against a fixture shaped like the real page, registered with ctest so CI runs it; dry-run of the notes step against this repo's real tags gives the link for 1.102.3-mavericks.1 and nothing for a 1.102.3-mavericks.2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9gMFvCFyNcoBohvUBRPqZ chore(deps): update dependency tailscale-source to v1.102.3 (ModernMavericks#11) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> build: gitignore the in-tree updater build, and the AppleDouble litter release.yml configures the Sparkle updater with `cmake -S updater -B build/updater`. Reproducing that step locally leaves a 7.4MB CMake tree in the checkout that is untracked AND unignored -- one `git add -A` away from being committed. Its CMakeCache.txt still resolved MavericksSharedCMake_DIR months after the rename, which is what a stale cache does: it keeps working against a package that no longer exists by that name, until it doesn't. Deleted the stale tree; golang already ignores build/updater/ the same way. Also ignoring ._* and .DS_Store. This checkout is on NFS and the Finder scatters AppleDouble files through it, including ._pack-*.idx inside .git -- which is why git prints "error: non-monotonic index" before the output of nearly every command here. 44 of them removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7TA921uMsVzUkEL8Kwfgy chore(deps): update dependency mavericks-golang to v1.26.8-mavericks.1 (ModernMavericks#12) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ci: a run that can publish is cancelled by nothing cancel-in-progress:false protects the RUNNING job, not the QUEUED one: GitHub keeps only the newest pending run per concurrency group and cancels the rest. The old shared group therefore discarded runs silently -- no red anywhere, the run just never happened. mavericks-golang lost one on 2026-09-09, 13 seconds after the run that evicted it, and a throwaway probe reproduced it on demand: two same-SHA dispatches merely queue, and the THIRD cancels the queued one. Only pull_request supersedes now, keyed per ref so a force-push replaces its own predecessor. A branch push, a *-mavericks.* tag and a workflow_dispatch are each keyed per run and alone in their group. Branch pushes are on the never-cancelled side because in eight of the thirteen product repos a push to main can auto-cut a release, and concurrency is resolved when a run is queued -- before the ver step decides whether this one publishes -- so cancelling the run would cancel the publish job with it. The cost is runner minutes on every main build; the alternative was a half-uploaded release whose tag is already minted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7TA921uMsVzUkEL8Kwfgy Adapt to the shipyard rename (CMake package, env var, refs) shared-cmake became ModernMavericks/shipyard, and @v1 already moved. GitHub redirects the API and git, but Actions does NOT follow a repo rename when resolving `uses:` — so the next push here would have produced a run with no jobs, named after the workflow file, with no logs to read. golang hit exactly that. Renamed throughout, matching what macho-tools and golang already ship: ModernMavericks/shared-cmake -> ModernMavericks/shipyard (reusable workflows) find_package(MavericksSharedCMake) -> MavericksShipyard (install@v1 registers only the new name, so the old find_package would fail at configure) MavericksSharedCMake_SCRIPTS -> MavericksShipyard_SCRIPTS $MSC / MSC_SCRIPTS -> $SHIPYARD / SHIPYARD_SCRIPTS Verified: no old-name residue in any tracked file, every changed sh/yaml/json still parses, and check-family-conventions passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BBKx6EbaZpdRtmBNQQBN3b chore(deps): update dependency tailscale-source to v1.102.2 (ModernMavericks#10) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> chore(deps): update dependency tailscale-source to v1.102.1 (ModernMavericks#9) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> refactor(version): derive upstream from the pin; stop committing VERSION The committed VERSION said 1.98.8-mavericks.1 while releases were at 1.102.0-mavericks.9 -- two upstreams stale. It was harmless only because this workflow seeded from it and then advanced to the newest shipped tag, so the file was a floor that tags always overrode. A value nothing depends on is a value nobody maintains, and it sat there looking authoritative. Now both halves are derived: build/derive-upstream-version.sh UPSTREAM_VERSION from the pinned source's own VERSION.txt at the pinned REF, so a Renovate pin bump carries the version with it and the two cannot disagree resolve-version.sh (shared) <upstream>-mavericks.N from that plus the shipped tags The forward-only guard stays and gets stronger: it compares the pin against the newest upstream among the TAGS rather than against a committed file, so a pin edit pointing backwards is still a no-op and cannot auto-publish a downgrade. The N+1 and N=1 arithmetic is gone -- auto mode yields N=1 for an upstream with no tags, which is exactly what an upstream_release means. ci.yml derives the upstream before configuring and checks out with tags: without them cmake would label every CI build -mavericks.1 and hide what it is really building. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu chore(deps): update dependency mavericks-golang to v1.26.5-mavericks.6 (ModernMavericks#8) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> refactor(postinstall): stop the old systray via shared helper + gate Replace the inline TERM/KILL block with mav_stop_gui_instance from the shared postinstall-stop-gui.sh (staged beside the postinstall as stop-gui.sh, so it is present at install time). package_pkg.sh now also gates the assembled postinstall with assert_gui_relaunch_safe.sh (a GUI relaunch must be preceded by a stop) and verifies the staged snippet parses and defines the function -- so a missing/broken helper fails the build instead of silently regressing to two icons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> ci(release): gate appcast upgradeability before publishing Run assert_appcast_upgradeable.sh in the pre-publish conformance step: the release's numeric sparkle:version must order strictly after the last published one, so a client on the previous release actually sees this as an update. Artifact conformance only checked that artifacts agree; it never exercised update ordering -- the gap the "-mavericks.N == equal -> you're up to date" bug shipped through. Tags are present (fetch-depth: 0), so the gate never silently degrades. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(pkg): build the component via shared build_component_pkg.sh + gate tailscale's .pkg used a bare pkgbuild -- so its menu-bar app + updater were both relocatable AND version-checked. The rename to "Mavericks Tailscale.app" escaped the relocation bug only by luck (no same-identifier bundle was registered on the box at install time); one such bundle away, it would have hit the same container-tools failure. Route through the shared build_component_pkg.sh (flips both flags off on every bundle) and gate the product archive with assert_pkg_installs_in_place.sh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(ci): serialize local_release dispatches instead of isolating them Keying the dispatch concurrency group on github.run_id gave every run its own group, which makes concurrency a no-op. Two dispatches — a manual cut racing the ingredient-bump auto-repackage — would each read the same tags, each compute -mavericks.(N+1), and collide on the tag. All dispatches now share one group with cancel-in-progress:false: a version-bump lock, so the second queues behind the first and sees its tag. Tag pushes keep their own per-ref group, so a publish is still never cancelled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu feat: digest-pin the tailscale source via shared clone_pinned Consume shared-cmake's clone_pinned.sh (delete the byte-identical vendored copy) and pin the tailscale source by commit DIGEST, not just the mutable tag. components/ tailscale/version gains DIGEST=; mavericks_tailscale_read_pin reads it; the clone passes it so the checkout is verified to be exactly that commit or the build bails. Renovate keeps REF+DIGEST in sync via git-refs/currentDigest (switched from github-tags -- git-refs is the proven combo: on container-tools it just wrote the peeled *commit* sha for a docker/cli bump, which is what clone_pinned verifies). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> systray: quit on SIGTERM instead of only cleaning up The systray's SIGTERM handler called menu.onExit() (cancel contexts, remove the temp icon) but never unwound the menu-bar app's run loop, so the process and its icon lingered. launchd stops a LaunchAgent with SIGTERM first (logout, `launchctl unload`/`bootout`, a KeepAlive restart) and only SIGKILLs after a timeout, so a well-behaved agent must terminate on SIGTERM. Call systray.Quit() -- the same teardown the menu's Quit item already uses (proven to cleanly exit), which also runs onExit via the library's exit callback. Correct cross-platform, so ungated. Makes the install-time postinstall kill a backstop rather than the only mechanism, and fixes clean logout/unload. Folded into the existing systray patch (6th hunk); applies cleanly to the v1.102.0 pin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> naming: app identity "Mavericks Tailscale", prose "Tailscale for Mavericks" Adopt the family two-register naming convention. App identity (the .app bundle, CFBundleName/DisplayName, the Sparkle updater PRODUCT_NAME + CONFIRM_TITLE) becomes "Mavericks Tailscale" -- brand-forward, discoverable, matching the mavericks-tailscale repo. Longer/prose strings (the .pkg installer --title, the appcast --channel-title) stay "Tailscale for Mavericks", which reads as "the real Tailscale, for the Mavericks OS" (nominative, not co-branded; the unofficial-build disclaimer stays). The menu-bar bundle is now "/Applications/Mavericks Tailscale.app"; updated the LaunchAgent ProgramArguments, the postinstall pkill path, package_pkg.sh, and the make_app.sh invocation (quoted for the space). Functional identifiers are unchanged: the tailscale/tailscaled/tailscale-systray binaries, dev.modernmavericks.* bundle IDs, com.tailscale.* launchd labels, hostinfo.SetPackage("ModernMavericks"), and the internal TailscaleUpdater.app helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(systray): launch the updater via `open`, not fork+exec The macOS "Check for updates" item fork+exec'd the updater executable directly. Sparkle's package install needs a LaunchServices-launched host — a fork+exec child has no LaunchServices session, so its privileged installer can't run (AuthorizationExecuteWithPrivileges -> -60008). It has worked here so far only by luck (that path is documented as flaky). Launch via `/usr/bin/open <updater>.app --args --user` instead. Same fix as mavericks-container-tools' menu bar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> ci: check artifact conformance before publishing The package job already only runs when publishing, so the question is asked exactly when it matters: do the .pkg, the appcast and the version about to be published agree with each other and with the family? Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu postinstall: kill the old menu-bar process so an update doesn't leave two icons Installing/updating the .pkg started the new systray via `launchctl load -w` but left the old one running, so the user saw two menu-bar icons until manually quitting the old one. Root cause: the systray's SIGTERM handler (client/systray Run) calls onExit(), which only cancels contexts and removes the temp icon file -- it never calls systray.Quit(), so the process keeps its menu-bar item and stays alive. So `launchctl unload`'s SIGTERM can't remove the old icon (and on 10.9 the asuser-nested unload doesn't reliably deliver the signal anyway). Fix in postinstall (its job is to cleanly replace the running app): before the `load -w`, explicitly stop any running old systray owned by the console user -- graceful TERM, then a guaranteed KILL -- so exactly one instance remains. Verified the pkill pattern matches only the running tailscale-systray (not tailscaled) on the 10.9 box. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> chore(deps): update dependency mavericks-golang to v1.26.5-mavericks.5 (ModernMavericks#7) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> patches: re-fit systray.go.patch to the v1.102.0 source pin The tailscale source pin is v1.102.0 (components/tailscale/version, bumped in 314eb55), but the systray patch was written against v1.98.8 and its title-gate hunk no longer applied: upstream moved systray.SetTitle("tailscale") out of onReady() and into Run() (with a new "must be called before systray.Run" comment). Regenerate the patch against the v1.102.0 checkout so all five hunks apply; the logic is unchanged. Verified: all three patches apply cleanly to .srccache/tailscale-v1.102.0, and every API the patch uses still exists there (local.Client.StartLoginInteractive, ipnstate.Status.BackendState, the onClick helper, ipn NeedsLogin/NoState). A full local compile isn't possible on this box -- its go126 .pkg is 1.26.4 and v1.102.0's go.mod requires 1.26.5 -- but CI builds with the pinned 1.26.5-mavericks.1 toolchain (components/golang/version). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> app: give Tailscale.app the Tailscale icon (shared with the updater) Tailscale.app (the menu-bar systray bundle) set no CFBundleIconFile, so Finder and the About box showed the generic app icon. The updater .app already carried the Tailscale logo mark via updater/tailscale-updater.icns -- which despite its name is just the product logo, not an updater-specific asset. Promote that icns to a shared product asset at the repo root (Tailscale.icns, with ICON-CREDIT.txt alongside) and have both bundles use it: - make_app.sh takes an optional .icns, installs it into the bundle Resources, and sets CFBundleIconFile (omitting it stays valid -> no custom icon). - release.yml passes Tailscale.icns when wrapping the systray app. - updater/CMakeLists.txt points ICON at ../Tailscale.icns. Verified make_app.sh locally: with an icon the bundle gets Resources/Tailscale.icns + CFBundleIconFile and lints clean; without one it still lints clean; a missing icon path fails loudly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> patches: drop pkgsrc-isms — natural filenames, no $NetBSD$ headers This repo isn't pkgsrc; the only real constraint is that build_tailscale.sh applies these with `patch -p0`, so they must be valid unified diffs. Everything else was cargo-culted from the pkgsrc port: - Rename patch-<mangled_path>.go to <basename>.go.patch (matching the existing overlays/certstore_darwin.go.patch style): systray.go.patch, tailscaled.go.patch, hostinfo_darwin.go.patch. - Strip the dead `$NetBSD: ... Exp $` RCS header and the ignored `.orig` timestamp from each; keep a plain human description. - build_tailscale.sh now globs patches/*.patch instead of patches/patch-*. - Also add systray.go.patch to tailscale_go.cmake DEPENDS (it was missing, so CMake wouldn't rebuild when that patch changed). Verified: full build applies all three and produces the binaries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> tailscale/systray: add macOS "Check for updates" -> Sparkle updater Add a "Check for updates" item to the systray menu (macOS only, just above Quit). Clicking it launches the bundled updater executable /Library/Application Support/ModernMavericks/TailscaleUpdater.app/Contents/MacOS/TailscaleUpdater --user which runs Sparkle's interactive check (update dialog or "you're up to date"). That's the same executable the daily LaunchAgent runs with --background; the child is reaped so the long-lived menu-bar app leaves no zombies. Gated to darwin and folded into the existing systray patch. Verified: full build compiles and both the menu label and updater path are in the binary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> tailscale: menu-bar icon-only, Connect logs in, report plain upstream version Three post-install fixups against the shipped .pkg: 1. Menu bar showed literal "tailscale" text next to the icon. That string comes from client/systray SetTitle("tailscale"), which upstream uses only as the Linux StatusNotifierItem ID; on macOS there's no SNI so it rendered as visible text. Gate it to non-darwin so only the icon shows. 2. "Connect" on a logged-out node only set WantRunning and never triggered login, so no BrowseToURL was emitted and the machine stayed down until the user ran `tailscale up` by hand. Connect now starts interactive login when the backend is NeedsLogin/NoState (like `tailscale up`), so tailscaled emits the auth URL and watchIPNBus opens it in the system browser. (1) and (2) are one new patch: patches/patch-client_systray_systray.go. 3. Reported "1.98.8-mavericks.1" to the control server (Hostinfo.IPNVersion is version.Long()). Stamp longStamp with the clean upstream semver too, so the tailnet sees "1.98.8" like other downstream packages. Package identity is still carried by hostinfo.SetPackage("ModernMavericks"). Verified via the real build_tailscale.sh flow: all patches apply, all three binaries compile, and `tailscale version` reports 1.98.8 with no -mavericks suffix in the binaries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> ci: publish via the shared publish-release workflow The bespoke publish job becomes six lines. The shared workflow regenerates SHA256SUMS (so the local shasum step goes, and the checksums now cover the appcast too), takes RELEASE_NOTES.md from the artifact as the Release body, and fails if that file is missing or empty. That last part is the point of the increment: this repo published an empty body on every release — including hand-tagged ones that had a committed notes file — because the wiring that decides whether anyone reads the notes was per-repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu docs+ci: record build ingredients; adopt the family conventions gate INGREDIENTS.md records the stable-only pin rule (tailscale ships unstable as odd minor versions), both auto-cutters and which axis each owns, and why neither commits to main: branch protection requires a status check a fresh bot commit cannot have, so the old commit-then-tag shape was rejected and cut no release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu ci: release notes name which ingredients changed; publish them as the Release body A repackage exists to ship a new build input, so its notes should name that input. The notes step now appends a generated "Build ingredients" section — each changed pin with its old and new value, computed from the previous release tag — and that same file becomes the GitHub Release body via body_path. The body was previously empty for EVERY release here, including hand-tagged ones that had a committed release-notes file: nothing ever passed it to action-gh-release. Now the Release page and the Sparkle appcast read one file. The package job's checkout gains fetch-depth: 0, since the notes diff pins against a tag. ci.yml gains the pin-declaration guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu fix(ci): release-on-bump dispatches instead of pushing to protected main The v1.102.0 bump just proved the old shape can't work here: release-on-bump committed VERSION + notes and pushed to main, and the push was rejected — "Protected branch update failed ... Required status check 'Cross-build + compat gate (macos-26)' is expected". A fresh bot commit has no check run, so branch protection declines it; no upstream release got cut. (A GITHUB_TOKEN-pushed tag would not have triggered release.yml either.) So release-on-bump becomes a thin trigger and release.yml owns every version decision, with three publishing paths — manual tag, upstream_release (new upstream, -mavericks.1, forward-only), local_release (repackage, -mavericks.N+1) — all publishing inline with the tag minted by action-gh-release. Nothing commits to main anymore. Shipped state now comes from the TAGS, seeded from VERSION for the no-tags case, because a dispatch-cut version is never committed back: reading the stale file would cut 1.98.8-mavericks.3 after 1.102.0 had shipped. The stub release notes follow the release kind rather than always claiming "repackage". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu chore(deps): update dependency tailscale-source to v1.102.0 (ModernMavericks#3) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ci: drop the golang-specific repackage workflow (superseded by the shared caller) repackage-on-ingredient-bump.yml covers this and every other ingredient: pins live in files, the shared reusable workflow decides, release.yml's local_release path cuts+publishes. The golang-only variant also pushed its own tag, which a GITHUB_TOKEN push cannot use to trigger release.yml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu ci: repackage on a build-ingredient bump (dispatch); local_release publishes inline release.yml grows a workflow_dispatch local_release input: it cuts a same-upstream -mavericks.(N+1) and publishes it in the same run (tag minted by action-gh-release via tag_name + target_commitish, since a GITHUB_TOKEN-pushed tag triggers nothing). The shared reusable repackage-on-ingredient-bump workflow dispatches exactly that when a components/ pin moves; the tailscale source pin is excluded as this repo's own upstream (N=1, release-on-bump.yml). N comes from the highest existing <upstream>-mavericks.* tag, not the committed VERSION file: a dispatch-cut version is never committed back, so deriving from the file would recompute the same N+1 on the next bump and overwrite the release instead of cutting the next one. Replaces the never-pushed golang-specific repackage-on-golang-bump.yml (preserved locally at refs/heads/superseded/repackage-on-golang-bump). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu ci: move golang toolchain pin into components/golang/version (Renovate-tracked file) Ingredient pins belong in files, not workflow env lines: a file is a path a push-paths filter can watch, which is what repackage-on-ingredient-bump.yml needs to turn a toolchain bump into a -mavericks.(N+1) repackage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWNdgyPLMsKGV7m9bMJywu Merge pull request ModernMavericks#6 from ModernMavericks/add-repackage-workflow ci: auto-cut -mavericks.(N+1) repackage on a golang toolchain bump ci: auto-cut -mavericks.(N+1) repackage on a golang toolchain bump Update dependency mavericks-golang to v1.26.5-mavericks.1 (ModernMavericks#5) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ci: accept golang's renamed cross asset (golang- prefix, fallback go126-) chore: point agents at the modernmavericks conventions plugin (shared-cmake marketplace) CI + release pipeline + Renovate auto-release Source is upstream tailscale/tailscale pinned by release tag; the go126 toolchain + our patches/overlays are the entire 10.9 story (no fork needed for Mavericks). ci.yml (push + pull_request, macos-26): cross-build the three Go binaries with the mavericks-golang go126 toolchain, run the compat gate, build the Sparkle updater (assert no tailscale linkage). No publish. This is also the check that gates Renovate: a bump PR must pass it before auto-merge (automerge inherited from the shared-cmake preset; no branch protection, no platformAutomerge override -- same as every other MM repo). release.yml (tags only): same build, then assemble + EdDSA-sign the .pkg via the shared sign_and_appcast.sh, verify the 10.9.5 floor, publish .pkg + appcast + SHA256SUMS. release-on-bump.yml: when the pin moves forward to a newer upstream, derive VERSION=<upstream>-mavericks.1 from the pinned source's VERSION.txt, commit it + a notes stub, push the tag release.yml publishes. FORWARD-ONLY guard. renovate.json: track upstream tailscale via github-tags (STABLE only -- even minor); plus the mavericks-golang toolchain (ci.yml + release.yml). A tailscale bump needing a newer Go than our go126 fails ci.yml (GOTOOLCHAIN=local) and so is NOT auto-merged -- it waits for mavericks-golang to catch up. Unofficial community build (see release notes). SPARKLE_PRIVATE_KEY must be set as a repo secret before the first tag build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> updater .app + dist launchd/scripts + .pkg assembly Sparkle/Cocoa auto-updater (updater/, a separate LANGUAGES OBJC project via mavericks_add_updater_app) links Sparkle+Cocoa and nothing from Tailscale; feeds from the ModernMavericks/tailscale appcast, EdDSA-verified against the committed public key. Its icon is Tailscale's official logo mark (faithful repackage; see updater/ICON-CREDIT.txt). dist/ carries the tailscaled LaunchDaemon, the systray LaunchAgent, and pre/postinstall scripts that reload the daemon and load the systray + daily update-check agents into the console user's GUI session. cmake/make_app.sh wraps the tailscale-systray Go binary in an LSUIElement .app. cmake/package_pkg.sh stages the payload (tailscaled -> /usr/local/sbin, tailscale -> /usr/local/bin, Tailscale.app -> /Applications, plists -> /Library/Launch*), hoists the shared stage_updater.sh (updater .app + updatecheck agent + rendered agent-load.sh), pkgbuilds a flat component, and stamps the hard 10.9.5 install floor via the shared set_install_floor.sh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> scaffold + native build of the three Go binaries Cross/native CMake project (LANGUAGES NONE; per-mode preset required) that builds tailscaled + tailscale + tailscale-systray for darwin/amd64 min-10.9 from the pinned upstream tailscale/tailscale release tag (components/tailscale/version), using the ModernMavericks go126 toolchain (its go.env CC wrapper supplies the 10.9 SDK + target flags + legacy shim). build_tailscale.sh clones the pin, applies our patches/ source tweaks + overlays/ third-party 10.9-SDK shims, vendors, and -linkmode=external builds each binary on LOCAL disk (repo is on NFS). It stamps both version.longStamp (1.98.8-mavericks.1) and version.shortStamp (clean upstream 1.98.8), as pkgsrc does. GOTOOLCHAIN=local pins the toolchain: Go may never auto-download a stock toolchain to satisfy a go.mod `go`/`toolchain` directive (stock Go isn't 10.9-safe and lacks our CC wrapper). A tailscale version needing a newer Go than our go126 fails LOUDLY here rather than silently escaping the patched toolchain. Each binary is gated by the shared assert_binary_compatible.sh (x86_64 / min-10.9 / _clock_gettime defined / no post-10.9 imports). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This pull request adds support for building and releasing Tailscale binaries with a macOS 10.6 (Snow Leopard) deployment floor, alongside the existing 10.9 Mavericks builds. It introduces new CMake presets, build scripts, and CI/release workflow steps to cross-build, test, and package binaries and an updater app compatible with 10.6. The release pipeline is updated to publish separate 10.6-targeted artifacts and appcast feeds.
The most important changes are:
Build system and CMake presets:
cross-legacypreset toCMakePresets.jsonto enable building for macOS 10.6 on modern hosts, setting the appropriate deployment floor and build variables.cmake/build_tailscale.shto support a "floor" argument, enabling builds for both 10.9 and 10.6, with special handling for symbol shims, toolchain wrappers, and GOAMD64 baseline for Snow Leopard compatibility. [1] [2] [3]cmake/legacy106/mavericks-cross-clang-106, a cross-compiler wrapper for macOS 10.6, ensuring correct SDK, symbols, and deployment target.Updater and packaging for 10.6:
cmake/build_updater106.shto build a minimal Objective-C updater app for 10.6, with a dedicated Info.plist and configurable appcast feed URL.appcast-10.6.xml) for legacy updater compatibility. [1] [2] [3]CI and release workflow integration:
build-legacy106job to.github/workflows/ci.ymlfor cross-building and testing 10.6-floor binaries and updater on macOS-26 runners..github/workflows/release.ymlto cache both 10.9 and 10.6 SDKs, build and gate 10.6-floor binaries, package and upload them, and publish a separate appcast for 10.6. [1] [2] [3] [4] [5]These changes ensure Tailscale can be built and distributed with full support for legacy macOS 10.6 systems, including automatic updates via a dedicated updater and feed.