Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 61 additions & 35 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Package

# Builds monitor.app, zips it, and — when it is given a release tag — attaches
# the zip to that release.
# Builds monitor.app and the headless tools, packages them as a disk image and
# a zip, and — when it is given a release tag — attaches both to that release.
#
# It is a reusable workflow rather than a step in the release workflow because
# a release can begin two ways: a merge to main that bumps the version, or a
Expand All @@ -11,7 +11,7 @@ on:
workflow_call:
inputs:
tag:
description: Release tag to attach the zip to. Omit to only build it.
description: Release tag to attach the files to. Omit to only build them.
required: false
type: string
workflow_dispatch:
Expand All @@ -21,7 +21,7 @@ permissions:

jobs:
package:
name: Build and zip monitor.app
name: Build and package monitor.app
# A self-hosted runner — a Mac on a desk. Only a merge to main, a published
# release or a manual run reaches this workflow, and all three need write
# access to the repository, so there is no fork path to guard against.
Expand All @@ -38,7 +38,7 @@ jobs:
- name: Swift version
run: swift --version

# A published zip must not contain code that fails its own gates, and a
# A published build must not contain code that fails its own gates, and a
# tag can point anywhere — including at a commit that never ran CI.
- name: Test
run: swift test
Expand All @@ -53,65 +53,91 @@ jobs:
#
# Unset, a release is ad-hoc signed and unnotarized, exactly as before.
# Set, a failure to sign or notarize fails the release rather than
# quietly publishing a zip nobody can open. See docs/signing.md.
# quietly publishing files nobody can open. See docs/signing.md.
- name: Build monitor.app
env:
MONITOR_SIGN_IDENTITY: ${{ vars.SIGN_IDENTITY }}
run: Scripts/make-app.sh

# ditto, not zip: a bundle holds symlinks and extended attributes, and
# zip flattens them. This is the command Apple's own notarization
# instructions use.
# Before packaging, because the app has to be stapled before it goes into
# the disk image: stapler writes into the bundle, so an image made first
# would hold an unstapled app. The tools go in the same submission.
- name: Notarize
if: vars.NOTARY_PROFILE != ''
env:
MONITOR_NOTARY_PROFILE: ${{ vars.NOTARY_PROFILE }}
run: Scripts/notarize.sh .build/package

# Two files, not one. The app goes in a disk image beside a link to
# /Applications, so installing it is one drag. The headless tools go in a
# zip of their own: binaries in the drag window would make it unclear what
# to drag.
- name: Package
id: package
env:
MONITOR_SIGN_IDENTITY: ${{ vars.SIGN_IDENTITY }}
MONITOR_NOTARY_PROFILE: ${{ vars.NOTARY_PROFILE }}
run: |
set -euo pipefail
version="$(sed -n 's/.*static let string = "\(.*\)".*/\1/p' \
Sources/MonitorCore/Version.swift)"
name="monitor-${version}.zip"
# The package dir holds monitor.app/, monitord, monitor-exporter and
# install-exporter.sh side by side; ditto without --keepParent puts
# them all at the top level of the zip.
ditto -c -k .build/package "$name"
echo "name=$name" >> "$GITHUB_OUTPUT"
dmg="monitor-${version}.dmg"
zip="monitor-tools-${version}.zip"

# Between zipping and the checksum, because notarize.sh rebuilds the zip
# around the stapled bundle: a checksum taken before this describes a file
# that no longer exists.
- name: Notarize
if: vars.NOTARY_PROFILE != ''
env:
MONITOR_NOTARY_PROFILE: ${{ vars.NOTARY_PROFILE }}
run: Scripts/notarize.sh .build/package "${{ steps.package.outputs.name }}"
Scripts/make-dmg.sh .build/package/monitor.app "$dmg"

# Everything make-app.sh staged except the app. ditto, not zip, and
# without --keepParent so the tools sit at the top level of the zip,
# where install-exporter.sh expects to find the binary beside it.
#
# No extended attributes: the runner tags every file with
# com.apple.provenance, and ditto stores each attribute as a `._` file
# beside it, which `unzip` in Terminal leaves behind as clutter. The
# tools lose nothing, since a binary's signature is inside the file.
rm -rf .build/tools
mkdir .build/tools
rsync -a --exclude monitor.app .build/package/ .build/tools/
ditto -c -k --norsrc --noextattr --noacl .build/tools "$zip"

echo "dmg=$dmg" >> "$GITHUB_OUTPUT"
echo "zip=$zip" >> "$GITHUB_OUTPUT"
ls -lh "$dmg" "$zip"

# After every step that writes the files, so each checksum describes
# what is uploaded. The checksums ride along as files rather than lines
# in the notes: re-running a failed publish then replaces them instead of
# appending a second copy.
- name: Checksum
id: checksum
env:
ZIP: ${{ steps.package.outputs.name }}
DMG: ${{ steps.package.outputs.dmg }}
ZIP: ${{ steps.package.outputs.zip }}
run: |
echo "sha=$(shasum -a 256 "$ZIP" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
ls -lh "$ZIP"
set -euo pipefail
shasum -a 256 "$DMG" > "$DMG.sha256"
shasum -a 256 "$ZIP" > "$ZIP.sha256"
cat "$DMG.sha256" "$ZIP.sha256"

# Uploaded on every run, released or not, so a manual run is a way to get
# a build without publishing anything.
- uses: actions/upload-artifact@v4
with:
name: monitor-app
path: ${{ steps.package.outputs.name }}
path: |
${{ steps.package.outputs.dmg }}
${{ steps.package.outputs.dmg }}.sha256
${{ steps.package.outputs.zip }}
${{ steps.package.outputs.zip }}.sha256
if-no-files-found: error

- name: Attach to release
if: inputs.tag != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
ZIP: ${{ steps.package.outputs.name }}
SHA: ${{ steps.checksum.outputs.sha }}
DMG: ${{ steps.package.outputs.dmg }}
ZIP: ${{ steps.package.outputs.zip }}
run: |
if ! command -v gh >/dev/null 2>&1; then brew install gh; fi
# The checksum rides along as a file rather than a line in the notes:
# re-running a failed publish then replaces it instead of appending a
# second copy.
echo "$SHA $ZIP" > "$ZIP.sha256"
# --clobber so a re-run replaces the assets rather than failing on a
# name that already exists.
gh release upload "$TAG" "$ZIP" "$ZIP.sha256" --clobber
gh release upload "$TAG" "$DMG" "$DMG.sha256" "$ZIP" "$ZIP.sha256" --clobber
18 changes: 12 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,17 @@ jobs:
command -v gh >/dev/null 2>&1 \
|| { echo "gh is not installed on this runner" >&2; exit 1; }
cat > notes.md <<'NOTES'
Download the zip below, unzip it, and drag `monitor.app` to
Applications. macOS 14 or later, Apple silicon or Intel.
Download `monitor-*.dmg` below, open it, and drag Monitor to
Applications. macOS 14 or later, Apple silicon.

The zip also contains `monitord`, a headless daemon that logs every
metric to rotating CSV files. Run `./monitord` to log at 1s with 1d
retention, or pass `--retention` and `--interval` to change it.
`monitor-tools-*.zip` holds the headless tools, for use without the
app:

- `monitord` logs every metric to rotating CSV files. Run
`./monitord` to log at 1s with 1d retention, or pass
`--retention` and `--interval` to change it.
- `monitor-exporter` serves the SMC and GPU sensors as Prometheus
metrics. `./install-exporter.sh` installs it as a launchd agent.
NOTES

if [ "$NOTARIZED" != "true" ]; then
Expand All @@ -233,7 +238,8 @@ jobs:
--generate-notes

# A merge that bumped the version, or a release published by hand. Either
# way the zip is built the same way and attached to the tag.
# way the disk image and the tools zip are built the same way and attached
# to the tag.
package:
name: Package
needs: tag
Expand Down
28 changes: 19 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ swift run monitord --version # version and the commit it was built from
swiftformat Sources Tests Plugins --lint --cache ignore # CI lint gate
Scripts/make-app.sh [dest] # wrap the release binary in monitor.app
Scripts/make-icon.swift out.icns # draw the app icon (make-app.sh calls this)
Scripts/notarize.sh app zip # notarize a Developer ID build and staple it
Scripts/notarize.sh .build/package # notarize the app and tools, staple the app
Scripts/make-dmg.sh app out.dmg # the app in a disk image beside /Applications
Scripts/test-release-pr-number.sh # the release label lookup's cases (CI runs it)
```

Expand Down Expand Up @@ -111,7 +112,8 @@ Plugins/
before every build, so the title bar cannot go stale
Scripts/ make-app.sh, which builds monitor.app, make-icon.swift,
which draws its icon, notarize.sh, which notarizes and
staples a Developer ID build, and release-pr-number.sh,
staples a Developer ID build, make-dmg.sh, which puts
the app in a disk image, and release-pr-number.sh,
which release.yml uses to find a merge's pull request
(tested by test-release-pr-number.sh)
Tests/ MonitorCoreTests, MonitorSourcesTests, MonitorStoreTests,
Expand All @@ -123,7 +125,8 @@ docs/ README.md is the index
ci.yml build, test, release build, CLI smoke tests (including
--help/--version and that monitord --help writes no CSV), lint
release.yml bumps the version on a merge to main, tags it, releases it
package.yml reusable: builds monitor.app, zips it, attaches it to a tag
package.yml reusable: builds monitor.app into a .dmg and the tools into a
zip, attaches both to a tag
```

One SwiftPM package: one build and one test command cover all of it, so there
Expand Down Expand Up @@ -351,7 +354,10 @@ are no component-level AGENTS.md files.
- **Releasing is a merge, nothing else.** `release.yml` bumps
`MonitorVersion.string` itself on every merge to main, commits it back as
`Version x.y.z [skip ci]`, tags it, publishes the release and attaches
`monitor-<version>.zip` built by `package.yml`. **Never remove the
`monitor-<version>.dmg` (the app) and `monitor-tools-<version>.zip`
(`monitord`, `monitor-exporter`, `install-exporter.sh`) built by
`package.yml`. Two files because a drag-to-Applications window offers one
gesture, and binaries beside the app would make it unclear what to drag. **Never remove the
`[skip ci]`** — it is what stops the workflow triggering itself into an
endless bump.
- **The default is a patch bump.** `release:minor` or `release:major` on the
Expand All @@ -376,19 +382,23 @@ are no component-level AGENTS.md files.
docs *and* code still ships.
- **Two escape hatches:** a pull request that sets `MonitorVersion.string`
itself ships exactly that version, and a release published by hand from the
GitHub UI gets its zip the same way.
GitHub UI gets its files the same way.
- **Signing is off until two repository variables are set.** `SIGN_IDENTITY`
and `NOTARY_PROFILE` turn on Developer ID signing and notarization in
`package.yml`; unset, a release is ad-hoc signed exactly as before. Set,
failing to sign or notarize fails the release rather than publishing a zip
failing to sign or notarize fails the release rather than publishing files
nobody can open. The credentials live in the Mac runner's keychain, not in
GitHub secrets — which is why the runner's LaunchAgent needs
`SessionCreate = false`: with the default `true`, every job gets its own
security session, cannot reach the login keychain, and `codesign` fails with
`errSecInternalComponent`. A signing runner cannot be headless.
**`notarize.sh` rebuilds the zip after stapling** — `stapler`
writes into the bundle, not the archive, so the uploaded copy is unstapled
until it is made again. `docs/signing.md` is the setup.
**Staple the app before packaging it** — `stapler` writes into the bundle,
not into any archive around it, so `notarize.sh` staples the app and only
then does `make-dmg.sh` put it in the image, which is notarized and stapled
in turn. `docs/signing.md` is the setup. A macOS update can reset the signing
key's partition list, and then `codesign` fails with the same
`errSecInternalComponent` (#61); `set-key-partition-list` at the Mac's own
console fixes it.
- **The title bar says which build this is.** The app's name with
`BuildStamp.label` under it — the commit and when it was made — as one block,
**white on black in the system font**, with the window's own title removed
Expand Down
27 changes: 15 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ and a headless CLI for reading the same metrics from a terminal.

Three executables, five libraries and a build plugin, in one SwiftPM package.

| Program | What it is | Shipped in the release zip |
|---------|------------|----------------------------|
| `monitor` | The SwiftUI app. Realtime panel of gauges and charts, ten minutes of in-memory history. | yes, as `monitor.app` |
| `monitord` | Headless daemon. Samples every metric on one clock and writes rotating CSV. | yes, as a bare binary |
| Program | What it is | Shipped in a release |
|---------|------------|----------------------|
| `monitor` | The SwiftUI app. Realtime panel of gauges and charts, ten minutes of in-memory history. | `monitor-*.dmg`, as `monitor.app` |
| `monitord` | Headless daemon. Samples every metric on one clock and writes rotating CSV. | `monitor-tools-*.zip`, as a bare binary |
| `monitorctl` | Headless CLI. Lists, reads and watches the same metrics in a terminal. | no — a development tool |
| `monitor-exporter` | Headless daemon. Serves the SMC and GPU sensors as Prometheus metrics on `GET /metrics`. | yes, as a bare binary |
| `monitor-exporter` | Headless daemon. Serves the SMC and GPU sensors as Prometheus metrics on `GET /metrics`. | `monitor-tools-*.zip`, as a bare binary |

| Library | What it holds |
|---------|---------------|
Expand All @@ -46,12 +46,14 @@ Swift constant, so the app's title bar cannot claim a stale build.

## Download

Grab the latest `monitor-*.zip` from
[Releases](https://github.com/evanwtf/monitor/releases/latest), unzip it, and
drag `monitor.app` to Applications. The zip also contains `monitord`,
`monitor-exporter` and `install-exporter.sh`, so a downloader runs either binary
with no toolchain — or `./install-exporter.sh` to set the exporter up as a
launchd service.
Each [release](https://github.com/evanwtf/monitor/releases/latest) has two
files. Apple silicon, macOS 14 or later.

- **`monitor-*.dmg`** is the app. Open it and drag Monitor onto the
Applications folder beside it.
- **`monitor-tools-*.zip`** holds `monitord`, `monitor-exporter` and
`install-exporter.sh`, so a downloader runs either binary with no toolchain —
or `./install-exporter.sh` to set the exporter up as a launchd service.

Releases are ad-hoc signed and not notarized unless the repository's
`SIGN_IDENTITY` and `NOTARY_PROFILE` variables are set, in which case the
Expand Down Expand Up @@ -200,7 +202,8 @@ than values.

CI runs build, test, release build, CLI smoke tests and the format check on
every pull request. Every merge to main ships a release: `release.yml` bumps
the version, tags it, and attaches the zip built by `package.yml`. A
the version, tags it, and attaches the disk image and the tools zip built by
`package.yml`. A
`release:minor`, `release:major` or `release:skip` label on the pull request
changes that; a merge touching only docs and workflows publishes nothing.

Expand Down
Loading
Loading