# macOS

The whole path on macOS: the darwin archive, quarantine, a launchd agent or daemon, and building it yourself.

Source: https://docs.jmrp.io/ghchronicle/install/macos/

The same static binary as everywhere else, built for `darwin` on both Apple
silicon and Intel. macOS is not a build target that is merely compiled and
hoped for: every change runs the whole unit suite and the end-to-end suite on a
macOS runner, beside Linux and Windows.

## The one-line install

```sh
curl -fsSL https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.sh | bash
```

It works out the architecture, takes the newest release, checks the archive
against the checksum that release published, and puts the binary in
`/usr/local/bin` when that is writable. When it is not, it takes the first of
`~/.local/bin` and `~/bin` that exists and is already on your `PATH`, and falls
back to `~/.local/bin` when neither is. A checksum that does not match stops it
without installing anything, and there is no flag to skip that step. When
`cosign` 2.4.2 or newer is already on the machine it also verifies that the
checksum file itself came from the release workflow, and a signature cosign
does not confirm stops it, with what cosign said. An older cosign cannot read
the signature bundle, so then, as with no cosign at all, it says that only the
checksum was verified. It ends by running what it installed, so
the version you see is the file it just wrote, and it says so when another
`ghchronicle` earlier in your `PATH` still wins the name.

Pin a version, or choose where it goes:

```sh
curl -fsSL https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.sh | VERSION=2.6.5 BIN_DIR=~/bin bash
```

> **Reading it first is the whole point of a short script**
>
> Piping anything into a shell means running whatever that URL answers with
> today. `curl -fsSL <url> -o install.sh`, read it, then `bash install.sh`: it
> is the same script, and it is short enough to read.

The rest of this page is that same install done by hand, which is what to
follow when you want to know exactly what landed where, or when you would
rather not run a script you did not write.

## Pick the archive

Release archives say `darwin`, which is the name of the system the Go toolchain
uses; macOS is the name Apple uses for the same thing. `uname -m` answers which
architecture.

| `uname -m` reports | The machine        | The archive to take |
| ------------------ | ------------------ | ------------------- |
| `arm64`            | Apple silicon      | `darwin_arm64`      |
| `x86_64`           | Intel              | `darwin_amd64`      |

```sh
VERSION=2.6.5
arch=$(uname -m); case "$arch" in x86_64) arch=amd64 ;; esac
base=https://github.com/jmrplens/ghchronicle/releases/download/v$VERSION
curl -fsSLO "$base/ghchronicle_${VERSION}_darwin_${arch}.tar.gz"
```

> **Name the version rather than asking for the newest**
>
> The repository publishes a moving major tag beside the numbered releases,
> because the Action is listed on the Marketplace and that listing needs one.
> That tag's release carries **no files at all**, so never build a download URL
> from the major tag: there is nothing behind it to download. Take the version
> from the
> [releases page](https://github.com/jmrplens/ghchronicle/releases) and write
> it down, as above.

## Check what you downloaded

Two files are published beside the archives: `checksums.txt`, which holds the
SHA-256 of every archive, and `checksums.txt.sigstore.json`, which is a
signature over that file.

1. Take the checksum file and its signature.

    ```sh
    curl -fsSLO "$base/checksums.txt"
    curl -fsSLO "$base/checksums.txt.sigstore.json"
    ```

2. Check the archive against it. macOS ships `shasum` rather than the
   `sha256sum` of a Linux box, so the line for your archive is selected and
   piped in, which works whatever version of `shasum` the system has.

    ```sh
    grep "darwin_${arch}.tar.gz$" checksums.txt | shasum -a 256 -c -
    ```

    ```text
    ghchronicle_2.6.5_darwin_arm64.tar.gz: OK
    ```

3. Check the checksum file itself, if you have
   [cosign](https://docs.sigstore.dev/cosign/system_config/installation/)
   2.4.2 or newer. An older one cannot read the bundle and fails whatever the
   file says.

    ```sh
    cosign verify-blob \
      --certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' \
      --certificate-oidc-issuer https://token.actions.githubusercontent.com \
      --bundle checksums.txt.sigstore.json \
      checksums.txt
    ```

    ```text
    Verified OK
    ```

The signing is keyless: the identity being verified is the workflow that ran,
recorded in a public transparency log, which is why the two `--certificate`
flags are not optional. Without them cosign would confirm that somebody signed
the file, which is not the question.

Then let it write the configuration:

```sh
ghchronicle -setup
```

It asks for a token and where the numbers go, checks each answer against the
thing it names, and writes a `config.yaml` and, if you want one, a launchd
agent in `~/Library/LaunchAgents`. The installer above offers to run it as its
last step.

## Put it on the PATH

The archive holds three files and no directory.

- ghchronicle_VERSION_darwin_ARCH.tar.gz
  - ghchronicle the binary
  - LICENSE
  - README.md

- **For everyone**

  ```sh
  tar -xzf "ghchronicle_${VERSION}_darwin_${arch}.tar.gz" ghchronicle
  sudo install -m 755 ghchronicle /usr/local/bin/
  ghchronicle -version
  ```

  `/usr/local/bin` is on the default `PATH` of every macOS install, on both
  architectures, because `/etc/paths` lists it first.

- **For one account**

  ```sh
  mkdir -p ~/bin
  tar -xzf "ghchronicle_${VERSION}_darwin_${arch}.tar.gz" -C ~/bin ghchronicle
  chmod 755 ~/bin/ghchronicle
  echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zprofile
  ```

  `~/bin` is not on the `PATH` by default, hence the last line. `zsh` is the
  login shell on every supported macOS.

> **If macOS refuses to run it**
>
> The binary carries no Developer ID signature and is not notarized, so a copy
> that arrives with the quarantine attribute is refused with "cannot be opened
> because the developer cannot be verified". The attribute is not carried
> inside the archive: it is set by whatever process writes a file, and
> inherited by the processes that one starts. A browser writes the `.tar.gz` it
> downloads with the mark on it, `curl` does not, and `tar -xzf` run from
> Terminal writes an unmarked binary either way. The case that bites is opening
> the archive in Finder, because Archive Utility passes the mark on to what it
> extracts. So look before you clear anything:
>
> ```sh
> xattr -l "ghchronicle_${VERSION}_darwin_${arch}.tar.gz"  # what a browser marked
> xattr -l ghchronicle                             # the extracted binary
> xattr -c ghchronicle                             # clear it
> ```
>
> `xattr -c` clears every extended attribute and succeeds when there are none.
> `xattr -d com.apple.quarantine` does not: on a binary extracted from Terminal
> it stops with `No such xattr: com.apple.quarantine`, which looks like a
> broken instruction and is only the attribute never having been there.

## Run it once

The binary needs a configuration file and a token, and
[the quickstart](https://docs.jmrp.io/ghchronicle/start/quickstart/) writes both in six steps. With
those in place:

```sh
ghchronicle -config config.yaml -list   # what would be collected
ghchronicle -config config.yaml -once   # one sweep, then exit
```

## Keep it running with launchd

launchd is what macOS has instead of systemd, and the choice it asks you to
make first is agent or daemon.

|            | A LaunchAgent             | A LaunchDaemon             |
| ---------- | ------------------------- | -------------------------- |
| Lives in   | `~/Library/LaunchAgents/` | `/Library/LaunchDaemons/`  |
| Runs as | you | `root`, or the `UserName` you give it |
| Runs when | you are logged in | the machine is up, from boot |
| Good for | a laptop you use | a Mac that stays on |

The agent is the one to start with. It needs no `sudo`, and a collector that
stops while the laptop's owner is logged out loses nothing that the next sweep
does not pick up.

```xml title="~/Library/LaunchAgents/io.jmrp.ghchronicle.plist"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>io.jmrp.ghchronicle</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/ghchronicle</string>
    <string>-config</string>
    <string>/Users/you/Library/Application Support/ghchronicle/config.yaml</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>GITHUB_TOKEN</key>
    <string>github_pat_...</string>
  </dict>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>StandardOutPath</key>
  <string>/Users/you/Library/Logs/ghchronicle.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/you/Library/Logs/ghchronicle.log</string>
</dict>
</plist>
```

1. Protect the file before it holds a token, then load it.

    ```sh
    chmod 600 ~/Library/LaunchAgents/io.jmrp.ghchronicle.plist
    launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.jmrp.ghchronicle.plist
    ```

2. See that it is up, and read what it says.

    ```sh
    launchctl print gui/$(id -u)/io.jmrp.ghchronicle
    tail -f ~/Library/Logs/ghchronicle.log
    ```

3. After editing the file, unload it and load it again. launchd reads the
   property list once, when it is bootstrapped.

    ```sh
    launchctl bootout gui/$(id -u)/io.jmrp.ghchronicle
    launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.jmrp.ghchronicle.plist
    ```

> **The token sits in the property list**
>
> A launchd job does not read your shell profile, so `EnvironmentVariables` is
> where the token has to be. That file is then the macOS equivalent of the
> systemd environment file, and it deserves the same treatment: mode `600`,
> never in a repository, and remembered when the token is rotated. `launchctl
> setenv` is the alternative, and it is worse: it puts the token in every
> process of the session.

### Or one sweep on a timer

`StartInterval` is launchd's cron, in seconds, and `-once` is the mode that
suits it. Replace `KeepAlive` with it and add `-once` to `ProgramArguments`:

```xml
  <key>StartInterval</key>
  <integer>3600</integer>
```

Keep `state_file` on a path that survives, in this mode above all. Without it
every run collects every family, whatever its cadence, and walks the stargazer
list, the whole star history and the co-authored pull requests again; the cache
file beside it is what lets a run ask GitHub only for what changed.

## Build it from source

Go 1.27.2 or newer is what the module declares. The build sets `CGO_ENABLED=0`,
so the Xcode command line tools are not needed for it.

- **go install**

  ```sh
  go install github.com/jmrplens/ghchronicle/v2/cmd/ghchronicle@latest
  ```

  Lands in `$(go env GOPATH)/bin`, which is `~/go/bin` unless you moved it,
  and that directory has to be on your `PATH`. A binary built this way has
  no commit or build date to report: those two are stamped by the release
  build, and a module downloaded through the proxy carries no checkout to
  read them from. `-version` names what the build does record instead, the
  module version the go command fetched and the Go release that compiled it:

  ```text
  ghchronicle 2.6.5 (module v2.6.5, built with <go version>)
  ```

- **make**

  ```sh
  git clone https://github.com/jmrplens/ghchronicle
  cd ghchronicle
  make build      # into bin/ghchronicle
  make install    # into GOBIN, stamped like a release build
  ```

  The Makefile is written for GNU Make 3.81, which is the version macOS
  ships, so the stock `make` runs it.

## Where the files go

The collector looks for a configuration file in no particular place: `-config`
defaults to `config.yaml` **relative to the working directory**, and there is
no search path behind it. macOS has no `/etc/ghchronicle` habit, so these are
the conventional places rather than ones the tool knows:

| File             | For an agent                                        | For a daemon                     |
| ---------------- | --------------------------------------------------- | -------------------------------- |
| Configuration    | `~/Library/Application Support/ghchronicle/`        | `/usr/local/etc/ghchronicle/`    |
| State, ledger, cache | `~/Library/Application Support/ghchronicle/`    | `/usr/local/var/ghchronicle/`    |
| Log              | `~/Library/Logs/ghchronicle.log`                    | `/usr/local/var/log/`            |

`state_file` has a default of its own, `ghchronicle-state.json` in the working
directory, with the write ledger beside it as `ghchronicle-state-written.bin`
and the cache as `ghchronicle-state-cache.bin`. A launchd job's working
directory is not something to rely on. Set it. From 2.6.1 on a path setting
takes a leading `~` as the home directory, so an agent's configuration can say
`state_file: ~/Library/Application Support/ghchronicle/state.json`; an older
release reads the `~` as a directory name, and wants `/Users/you/...` written
out.
