Skip to content

Add Snow Leopard (10.6) support with build and updater enhancements - #17

Open
startergo wants to merge 1 commit into
ModernMavericks:mainfrom
startergo:main
Open

startergo wants to merge 1 commit into
ModernMavericks:mainfrom
startergo:main

Conversation

@startergo

Copy link
Copy Markdown

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:

  • Added a new cross-legacy preset to CMakePresets.json to enable building for macOS 10.6 on modern hosts, setting the appropriate deployment floor and build variables.
  • Updated cmake/build_tailscale.sh to 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]
  • Added 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:

  • Added cmake/build_updater106.sh to build a minimal Objective-C updater app for 10.6, with a dedicated Info.plist and configurable appcast feed URL.
  • Updated packaging steps in the release workflow to include 10.6-specific binaries and updater app, and to generate a separate 10.6 appcast feed (appcast-10.6.xml) for legacy updater compatibility. [1] [2] [3]

CI and release workflow integration:

  • Added a new build-legacy106 job to .github/workflows/ci.yml for cross-building and testing 10.6-floor binaries and updater on macOS-26 runners.
  • Updated .github/workflows/release.yml to 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.

Copilot AI lite review requested due to automatic review settings September 15, 2026 01:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread updater/updater106.m
Comment thread tests/assert_legacy106_compatible.sh Outdated
Comment thread updater/updater106.m
Comment thread updater/updater106.m
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants