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
36 changes: 36 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: release

on:
push:
tags: ['v*']

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v5
with:
go-version-file: linux/go.mod
- name: Build linux binaries
working-directory: linux
run: |
mkdir -p ../dist
for arch in amd64 arm64; do
CGO_ENABLED=0 GOOS=linux GOARCH=$arch \
go build -trimpath -ldflags "-s -w" -o "../dist/gitwatchd-linux-$arch" .
done
- name: Checksums
working-directory: dist
run: sha256sum gitwatchd-linux-amd64 gitwatchd-linux-arm64 > SHA256SUMS
- name: Create release
working-directory: dist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
--title "$GITHUB_REF_NAME" --generate-notes \
gitwatchd-linux-amd64 gitwatchd-linux-arm64 SHA256SUMS
17 changes: 14 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,22 @@ on:
pull_request:

jobs:
test:
mac:
runs-on: macos-15
steps:
- uses: actions/checkout@v5
- name: Build app
run: make
run: make -C macos
- name: Run tests
run: make test
run: make -C macos test
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v5
with:
go-version-file: linux/go.mod
- name: Build
run: make -C linux
- name: Run tests
run: make -C linux test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ NOTES.md

# Code-signing material (private key + CSR); never part of this repo
signing/

# Go binary built in-place by `go build .` in linux/ (make builds into build/)
/linux/gitwatchd
158 changes: 18 additions & 140 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,140 +1,18 @@
# gitwatchd: Xcode-free build for a macOS menu-bar app.
# Compiles with swiftc and hand-assembles a .app bundle. No .xcodeproj, no Xcode.app.
#
# Tier 1: dev:
# make build build/gitwatchd.app
# make run build, then (re)launch from build/: the dev loop
# make stop kill any running instance
# make clean remove build artifacts
#
# Tier 2: local install (no sudo):
# make install → /Applications + CLI on PATH + launch; self-registers launch-at-login
# make uninstall remove app, CLI link, login item, and first-run state (re-onboards next install)
#
# Tier 3: distribution (needs a paid Apple Developer account):
# make sign-release DEV_ID="Developer ID Application: NAME (TEAMID)"
# make notarize NOTARY_PROFILE=<notarytool keychain profile>

APP_NAME := gitwatchd
BUNDLE_ID := com.gitwatchd.app
BUILD_DIR := build
APP_BUNDLE := $(BUILD_DIR)/$(APP_NAME).app
MACOS_DIR := $(APP_BUNDLE)/Contents/MacOS
RES_DIR := $(APP_BUNDLE)/Contents/Resources
BIN := $(MACOS_DIR)/$(APP_NAME)

SOURCES := $(wildcard Sources/*.swift)
SWIFTC := swiftc
SWIFT_FLAGS := -O -framework AppKit -framework CoreServices -framework ServiceManagement

# Tier 3 config (override on the command line):
DEV_ID ?= Developer ID Application: Will Howard (452SH7Q736)
NOTARY_PROFILE ?= gitwatchd-notary

.PHONY: all run stop clean install uninstall sign-release notarize test

all: $(APP_BUNDLE)

$(APP_BUNDLE): $(SOURCES) Resources/Info.plist Makefile
@echo "→ assembling $(APP_BUNDLE)"
@mkdir -p $(MACOS_DIR) $(RES_DIR)
@cp Resources/Info.plist $(APP_BUNDLE)/Contents/Info.plist
@$(SWIFTC) $(SWIFT_FLAGS) $(SOURCES) -o $(BIN)
@# ad-hoc sign so the app has a stable identity (needed for launch-at-login)
@codesign --force --sign - $(APP_BUNDLE) 2>/dev/null || true
@echo "✓ built $(APP_BUNDLE)"

# Tests: Swift Testing (@Test / #expect) via bare swiftc; still no SPM, no
# .xcodeproj. Engine + formatting sources compile together with Tests/ into one
# binary (Sources/main.swift is excluded: an app can't share top-level code
# with a test runner; Tests/main.swift hands the process to the test runner).
# Testing.framework ships with full Xcode, not the Command Line Tools, so this
# target needs Xcode installed; the app build itself does not.
TEST_BIN := $(BUILD_DIR)/gitwatchd-tests
TEST_SOURCES := $(filter-out Sources/main.swift,$(SOURCES)) $(wildcard Tests/*.swift)
PLATFORM_FWKS = $(shell xcrun --show-sdk-platform-path 2>/dev/null)/Developer/Library/Frameworks
SWIFT_PLUGINS = $(shell dirname $$(dirname $$(xcrun -f swiftc)))/lib/swift/host/plugins/testing

test:
@test -d "$(PLATFORM_FWKS)/Testing.framework" || \
{ echo "✗ Testing.framework not found. Tests need full Xcode installed (the app build doesn't)."; exit 1; }
@echo "→ building tests"
@mkdir -p $(BUILD_DIR)
@$(SWIFTC) $(SWIFT_FLAGS) $(TEST_SOURCES) \
-F "$(PLATFORM_FWKS)" \
-plugin-path "$(SWIFT_PLUGINS)" \
-Xlinker -rpath -Xlinker "$(PLATFORM_FWKS)" \
-o $(TEST_BIN)
@$(TEST_BIN)

run: stop all
@echo "→ launching $(APP_NAME) (dev, from build/)"
@open $(APP_BUNDLE)

stop:
@pkill -x $(APP_NAME) 2>/dev/null || true

# --- Tier 2: local install / uninstall ---
# Sudo-free: app → /Applications (or ~/Applications), CLI symlinked onto PATH, then
# launch: the daemon self-registers launch-at-login on the first run of an installed copy.

install: all
@pkill -x $(APP_NAME) 2>/dev/null || true
@# App destination: prefer /Applications, fall back to ~/Applications. No sudo.
@if [ -w /Applications ]; then APP_DEST=/Applications; \
else APP_DEST="$$HOME/Applications"; mkdir -p "$$APP_DEST"; fi; \
rm -rf "$$APP_DEST/$(APP_NAME).app"; \
cp -R "$(APP_BUNDLE)" "$$APP_DEST/"; \
echo "✓ app → $$APP_DEST/$(APP_NAME).app"; \
INNER="$$APP_DEST/$(APP_NAME).app/Contents/MacOS/$(APP_NAME)"; \
CLI_DEST=""; \
for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \
mkdir -p "$$d" 2>/dev/null || true; \
if [ -w "$$d" ]; then CLI_DEST="$$d"; break; fi; \
done; \
if [ -n "$$CLI_DEST" ]; then \
ln -sf "$$INNER" "$$CLI_DEST/$(APP_NAME)"; \
echo "✓ cli → $$CLI_DEST/$(APP_NAME)"; \
case ":$$PATH:" in *":$$CLI_DEST:"*) ;; \
*) echo " ⚠ $$CLI_DEST is not on your PATH. Add: export PATH=\"$$CLI_DEST:\$$PATH\"";; esac; \
else \
echo " ⚠ no writable bin dir found. Link manually: ln -sf \"$$INNER\" /usr/local/bin/$(APP_NAME)"; \
fi; \
open "$$APP_DEST/$(APP_NAME).app"
@echo "✓ launched $(APP_NAME): menu-bar icon, top-right; starts at login (approve once in System Settings)"
@echo " Next: $(APP_NAME) . to watch the current repo"

uninstall:
@pkill -x $(APP_NAME) 2>/dev/null || true
@# unregister launch-at-login from the installed bundle BEFORE deleting it
@for a in /Applications/$(APP_NAME).app "$$HOME/Applications/$(APP_NAME).app"; do \
[ -x "$$a/Contents/MacOS/$(APP_NAME)" ] && "$$a/Contents/MacOS/$(APP_NAME)" autostart off >/dev/null 2>&1 || true; \
done
@rm -rf /Applications/$(APP_NAME).app "$$HOME/Applications/$(APP_NAME).app"
@for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \
[ -L "$$d/$(APP_NAME)" ] && rm -f "$$d/$(APP_NAME)" && echo " removed $$d/$(APP_NAME)"; \
done; true
@rm -rf "$$HOME/Library/Application Support/$(APP_NAME)"
@echo "✓ uninstalled: app, CLI link, login item, and first-run state cleared"
@echo " (config at ~/.$(APP_NAME) kept: 'rm ~/.$(APP_NAME)' to reset watched repos too)"

# --- Tier 3: Developer ID sign + notarize (entirely CLI; no Xcode.app) ---

sign-release: all
@test -n "$(DEV_ID)" || { echo "✗ set DEV_ID=\"Developer ID Application: NAME (TEAMID)\""; exit 1; }
@codesign --force --options runtime --timestamp --sign "$(DEV_ID)" $(APP_BUNDLE)
@codesign --verify --strict --verbose=2 $(APP_BUNDLE)
@echo "✓ Developer ID signed"

notarize: sign-release
@ditto -c -k --keepParent $(APP_BUNDLE) $(BUILD_DIR)/$(APP_NAME)-notarize.zip
@xcrun notarytool submit $(BUILD_DIR)/$(APP_NAME)-notarize.zip --keychain-profile "$(NOTARY_PROFILE)" --wait
@# staple the .app, THEN zip the stapled app (a zip itself can't be stapled)
@xcrun stapler staple $(APP_BUNDLE)
@ditto -c -k --keepParent $(APP_BUNDLE) $(BUILD_DIR)/$(APP_NAME).zip
@shasum -a 256 $(BUILD_DIR)/$(APP_NAME).zip
@echo "✓ notarized + stapled → $(BUILD_DIR)/$(APP_NAME).zip"

clean:
@rm -rf $(BUILD_DIR)
@echo "✓ cleaned"
# gitwatchd: one implementation per platform, in macos/ and linux/.
# This Makefile picks the one for the current platform and forwards every
# target to it. Run make inside a platform directory to be explicit.

UNAME := $(shell uname -s)
ifeq ($(UNAME),Darwin)
PLATFORM := macos
else ifeq ($(UNAME),Linux)
PLATFORM := linux
else
$(error unsupported platform: $(UNAME))
endif

TARGETS := all run stop clean install uninstall test sign-release notarize

.PHONY: $(TARGETS)
$(TARGETS):
@$(MAKE) -C $(PLATFORM) $@
148 changes: 148 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/bin/sh
# gitwatchd installer. Run with:
# curl -fsSL https://raw.githubusercontent.com/Will-Howard/gitwatchd/main/install.sh | sh

set -u

REPO=https://github.com/Will-Howard/gitwatchd

say() { echo "$1" >&2; }
warn() { echo " ⚠ $1" >&2; }
err() { echo " ⚠ $1" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
ensure() { "$@" || err "command failed: $*"; }

cleanup() {
[ -n "${TMP:-}" ] && rm -rf "$TMP"
[ -n "${STAGED:-}" ] && rm -f "$STAGED"
return 0
}

detect_platform() {
os=$(uname -s)
arch=$(uname -m)
case "$arch" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
esac
if [ "$os" = Darwin ]; then
say "gitwatchd for macOS installs via Homebrew for the moment:"
say " brew tap will-howard/tap"
say " brew trust --cask will-howard/tap/gitwatchd"
say " brew install --cask gitwatchd"
exit 1
fi
if [ "$os" = Linux ] && [ "$(getconf LONG_BIT 2>/dev/null || echo 64)" = 32 ]; then
err "gitwatchd needs a 64-bit userland, this system reports 32-bit."
fi
case "$os-$arch" in
Linux-amd64 | Linux-arm64) ARTIFACT="gitwatchd-linux-$arch" ;;
*) err "no gitwatchd build for $os-$arch yet. Please open an issue: $REPO/issues" ;;
esac
}

resolve_source() {
if have curl; then
DOWNLOADER=curl
elif have wget; then
DOWNLOADER=wget
else
err "gitwatchd needs curl or wget to download itself. Install one and re-run."
fi
if [ -n "${GITWATCHD_DOWNLOAD_URL:-}" ]; then
BASE=${GITWATCHD_DOWNLOAD_URL%/}
elif [ -n "${GITWATCHD_VERSION:-}" ]; then
BASE="$REPO/releases/download/$GITWATCHD_VERSION"
else
BASE="$REPO/releases/latest/download"
fi
}

download() {
if [ "$DOWNLOADER" = curl ]; then
curl -fsSL "$1" -o "$2"
else
wget -q -O "$2" "$1"
fi
}

verify_checksum() {
if have sha256sum; then
got=$(sha256sum "$TMP/$ARTIFACT" | cut -d' ' -f1)
elif have shasum; then
got=$(shasum -a 256 "$TMP/$ARTIFACT" | cut -d' ' -f1)
else
warn "no sha256sum or shasum on this system, skipping checksum verification"
return 0
fi
if ! download "$BASE/SHA256SUMS" "$TMP/SHA256SUMS"; then
warn "could not download $BASE/SHA256SUMS, so the binary cannot be verified"
say " press Enter to install it unverified, Ctrl-C to abort (30s)"
timeout 30 head -n1 /dev/tty >/dev/null 2>&1 || err "not confirmed, aborting"
return 0
fi
want=$(awk -v a="$ARTIFACT" '$2 == a || $2 == "*" a {print $1}' "$TMP/SHA256SUMS")
[ -n "$want" ] || err "$ARTIFACT is not listed in SHA256SUMS"
if [ "$want" != "$got" ]; then
warn "checksum mismatch for $ARTIFACT, refusing to install:"
say " want $want"
say " got $got"
exit 1
fi
}

# Same search order as make install.
choose_dest() {
if [ -n "${GITWATCHD_INSTALL_DIR:-}" ]; then
ensure mkdir -p "$GITWATCHD_INSTALL_DIR"
DEST=${GITWATCHD_INSTALL_DIR%/}
return 0
fi
for d in /usr/local/bin "$HOME/.local/bin" "$HOME/bin"; do
mkdir -p "$d" 2>/dev/null || true
if [ -w "$d" ]; then
DEST=$d
return 0
fi
done
err "no writable bin dir found. Retry with GITWATCHD_INSTALL_DIR=<dir> set."
}

install_binary() {
[ -x "$DEST/gitwatchd" ] && "$DEST/gitwatchd" stop >/dev/null 2>&1
STAGED="$DEST/.gitwatchd.$$"
ensure cp "$TMP/$ARTIFACT" "$STAGED"
ensure chmod 0755 "$STAGED"
ensure mv -f "$STAGED" "$DEST/gitwatchd"
STAGED=
say "✓ binary → $DEST/gitwatchd"
}

start_daemon() {
case ":$PATH:" in
*":$DEST:"*) ;;
*) warn "$DEST is not on your PATH. Add: export PATH=\"$DEST:\$PATH\"" ;;
esac
[ -n "${GITHUB_PATH:-}" ] && echo "$DEST" >>"$GITHUB_PATH"
"$DEST/gitwatchd" start || err "$DEST/gitwatchd start failed"
say " Next: gitwatchd . to watch the current repo"
say " gitwatchd status to see what it is doing"
}

main() {
TMP=
STAGED=
trap cleanup EXIT
detect_platform
resolve_source
choose_dest
TMP=$(mktemp -d) || err "could not create a temporary directory"
download "$BASE/$ARTIFACT" "$TMP/$ARTIFACT" ||
err "could not download $BASE/$ARTIFACT"
[ -s "$TMP/$ARTIFACT" ] || err "$BASE/$ARTIFACT downloaded empty"
verify_checksum
install_binary
start_daemon
}

main "$@"
Loading
Loading