/

Feature

Astrolabe binaries can now update themselves

Ryan

No headings found on page

Start building
with Spectrum

Deploy AI agents
across every channel

Learn more about Spectrum

Preface

Preface

Astrolabe is a declarative macOS configuration framework. You describe the desired state of a machine in Swift — packages, services, system settings, and related setup work — and Astrolabe keeps the machine moving toward that state over time. This update adds a built-in way for the Astrolabe binary itself to update after it has been installed.

What is Astrolabe

Astrolabe is a Swift framework for describing how a Mac should be set up.

Instead of writing a script that runs commands once, you declare the packages, services, and settings that should exist. Astrolabe runs in the background and keeps checking that the machine still matches that definition.

A small setup looks like this:

@main
struct MySetup: Astrolabe {
    static var version: String { "1.2.3" }

    var body: some Setup {
        Pkg(.catalog(.homebrew))
        Brew("jq")
    }
}
@main
struct MySetup: Astrolabe {
    static var version: String { "1.2.3" }

    var body: some Setup {
        Pkg(.catalog(.homebrew))
        Brew("jq")
    }
}
@main
struct MySetup: Astrolabe {
    static var version: String { "1.2.3" }

    var body: some Setup {
        Pkg(.catalog(.homebrew))
        Brew("jq")
    }
}

Astrolabe runs as a LaunchDaemon. On each tick, it reads the current state, builds the desired setup tree, compares it with what it saw before, and runs the needed work. The goal is simple: keep Mac setup predictable without turning every machine into a one-off script.

Astrolabe binaries can now update themselves

Astrolabe now has a built-in self-update mechanism for the Astrolabe binary itself.

Before this change, Astrolabe could keep a Mac's configuration in sync, but the binary running that configuration still needed its own update path. If you shipped a new version of the Astrolabe binary, you had to get that new binary onto the machine and restart the daemon yourself.

Now a deployed Astrolabe binary can opt in to self-updates with static var update. When you install the daemon, Astrolabe also installs a sibling updater daemon for that same binary. The updater checks a release source, downloads a newer package for the binary, verifies it, installs it, and restarts the main daemon so the new binary is running.

The feature is meant for Astrolabe binaries that are already deployed to Macs. If you ship one binary to a fleet, that binary can now keep itself current instead of depending on a separate replacement path.

How to opt in

Every self-updating Astrolabe binary declares a version:

static var version: String { "1.2.3" }
static var version: String { "1.2.3" }
static var version: String { "1.2.3" }

That version is used by the updater to compare the local binary with the latest available release. Versions are parsed as SemVer, so releases like 1.2.3 and 1.2.3-beta.1 work as expected.

To make the binary self-updating, add an update configuration:

@main
struct MySetup: Astrolabe {
    static var version: String { "1.2.3" }

    static var update: UpdateConfiguration? {
        UpdateConfiguration(.gitHub("acme/mysetup"))
            .interval(.hours(1))
            .channel(.stable)
            .verify(.pkgSignatureRequired)
    }

    var body: some Setup {
        Pkg(.catalog(.homebrew))
        Brew("jq")
    }
}
@main
struct MySetup: Astrolabe {
    static var version: String { "1.2.3" }

    static var update: UpdateConfiguration? {
        UpdateConfiguration(.gitHub("acme/mysetup"))
            .interval(.hours(1))
            .channel(.stable)
            .verify(.pkgSignatureRequired)
    }

    var body: some Setup {
        Pkg(.catalog(.homebrew))
        Brew("jq")
    }
}
@main
struct MySetup: Astrolabe {
    static var version: String { "1.2.3" }

    static var update: UpdateConfiguration? {
        UpdateConfiguration(.gitHub("acme/mysetup"))
            .interval(.hours(1))
            .channel(.stable)
            .verify(.pkgSignatureRequired)
    }

    var body: some Setup {
        Pkg(.catalog(.homebrew))
        Brew("jq")
    }
}

The minimum version is even smaller:

static var update: UpdateConfiguration? {
    UpdateConfiguration(.gitHub("acme/mysetup"))
}
static var update: UpdateConfiguration? {
    UpdateConfiguration(.gitHub("acme/mysetup"))
}
static var update: UpdateConfiguration? {
    UpdateConfiguration(.gitHub("acme/mysetup"))
}

By default, Astrolabe checks the stable GitHub release channel once an hour and requires the downloaded package to pass pkgutil --check-signature.

What the updater does

When install-daemon runs, Astrolabe installs the main daemon as before. If update is configured, it also installs a second LaunchDaemon for the same binary, using a hidden update loop command.

On each update tick, the updater:

  1. Fetches the latest release from the configured source.

  2. Compares the release version against App.version.

  3. Skips the update if the local version is already current.

  4. Refuses downgrades by default.

  5. Downloads the matching .pkg for the newer Astrolabe binary.

  6. Verifies the package signature.

  7. Runs any preUpdate hook.

  8. Installs the package with /usr/sbin/installer.

  9. Runs any postUpdate hook.

  10. Restarts the main daemon with launchctl kickstart.

  11. Re-execs itself so the updater also runs from the new binary.

The main point is that the binary replacement happens out of process. The main Astrolabe daemon is not trying to overwrite itself while it is doing normal setup work. The updater has its own daemon, its own loop, and its own status.

Configuration options

The update API is small, but it covers the common cases:

static var update: UpdateConfiguration? {
    UpdateConfiguration(.gitHub("acme/mysetup", asset: .pkg))
        .interval(.hours(1))
        .channel(.stable)
        .verify(.codesignTeamID("ABCD123456"))
        .allowDowngrade(false)
        .githubToken(token)
        .preUpdate { from, to in
            try await backupConfigs()
        }
        .postUpdate { version in
            await reportToMDM(version)
        }
        .onFail { error in
            print(error)
        }
}
static var update: UpdateConfiguration? {
    UpdateConfiguration(.gitHub("acme/mysetup", asset: .pkg))
        .interval(.hours(1))
        .channel(.stable)
        .verify(.codesignTeamID("ABCD123456"))
        .allowDowngrade(false)
        .githubToken(token)
        .preUpdate { from, to in
            try await backupConfigs()
        }
        .postUpdate { version in
            await reportToMDM(version)
        }
        .onFail { error in
            print(error)
        }
}
static var update: UpdateConfiguration? {
    UpdateConfiguration(.gitHub("acme/mysetup", asset: .pkg))
        .interval(.hours(1))
        .channel(.stable)
        .verify(.codesignTeamID("ABCD123456"))
        .allowDowngrade(false)
        .githubToken(token)
        .preUpdate { from, to in
            try await backupConfigs()
        }
        .postUpdate { version in
            await reportToMDM(version)
        }
        .onFail { error in
            print(error)
        }
}

A few details matter:

  • .stable tracks the latest stable GitHub release.

  • .prerelease can include prerelease builds.

  • .pkgSignatureRequired requires a valid package signature.

  • .codesignTeamID(...) also checks that the signing Team ID matches what you expect.

  • .allowDowngrade(false) is the default.

  • .githubToken(...) lets the updater access private release sources for the binary.

  • Hooks let you run work before an update, after an update, or after a failure.

You can also pin a release tag:

UpdateConfiguration(.gitHub("acme/mysetup", version: .tag("v1.2.3")))
UpdateConfiguration(.gitHub("acme/mysetup", version: .tag("v1.2.3")))
UpdateConfiguration(.gitHub("acme/mysetup", version: .tag("v1.2.3")))

That is useful for staged rollouts or a controlled rollback path. The updater installs that tag once if it is newer than the local version, then no-ops.

Checking update status

Astrolabe also adds a public command:

sudo
sudo
sudo

It prints the current binary version, whether self-update is configured, the last check time, the last seen version, the last successful update time, and the last error.

Uninstall also understands the updater now:

sudo
sudo
sudo

That removes the updater daemon first, then the main daemon.

What to keep in mind

This feature does one specific thing: it updates the Astrolabe binary that is already installed on the Mac.

It does not update every app or package on the machine. For example, Astrolabe can still install and manage tools like Homebrew packages, but this self-update feature is only for replacing the Astrolabe binary itself with a newer version.

Try it

Learn more about Photon at photon.codes, or read the Astrolabe source on GitHub.


Subscribe Photon Newsletter

Subscribe
Photon Newsletter