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

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Build and test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
# The oldest toolchain the README promises, and the current one.
os: [macos-15, macos-26]
steps:
- uses: actions/checkout@v5

- name: Toolchain
run: |
xcodebuild -version
swift --version

- name: Install XcodeGen
run: command -v xcodegen >/dev/null || brew install xcodegen

- name: Scan for credentials
run: make scan

# No Local.xcconfig here, so this is also the proof that a fresh clone
# builds with ad-hoc signing and nothing to set up.
- name: Build and test
run: make test

- name: Release build
run: make release
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ secrets.json
token.txt

# ── Xcode / SwiftPM build output ─────────────────────────────────────
# BarRepo.xcodeproj is generated from project.yml by `make generate`.
# Cleared.xcodeproj is generated from project.yml by `make generate`.
*.xcodeproj/
*.xcworkspace/
xcuserdata/
DerivedData/
build/
dist/
.build/
.swiftpm/
*.xcuserstate
Expand Down
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Changelog

## 0.4.0

The release that makes Cleared something you can leave running.

### Fixed

- **Cleared did nothing until its icon was first clicked.** Polling started
from the panel, which macOS does not build until it is opened, so an app
launched at login watched nothing and announced nothing. It now starts with
the app.
- **A fresh clone did not build.** `project.yml` pointed at `Local.xcconfig`,
which is gitignored. The include is now optional.
- **Merge was offered on pull requests GitHub would refuse.** Readiness looked
only at checks and conflicts. It now also requires GitHub's own merge verdict,
so a pull request waiting on a required review, behind its base branch, or
blocked by a branch rule is listed under *In flight* with the reason.
- **A merge could include commits nobody had seen.** The merge is now pinned to
the commit Cleared last polled; a push in between is refused by GitHub.
- **The merge method setting failed outright on repositories that don't allow
it.** Those repositories now use their own default.
- **Start a workflow listed workflows that can't be started**, always sent no
inputs, and assumed the branch was `main`. It now lists only workflows with a
`workflow_dispatch` trigger, shows their declared inputs, and defaults to the
repository's default branch.
- **The "calls left" figure jumped around.** It mixed the REST and GraphQL
budgets; it now shows the REST budget, which is the one polling spends.
- **A revoked token caused an error banner on every poll.** Cleared now stops
and returns to the connect screen with an explanation.
- **Ignoring a repository could not be undone**, and pinned repositories had no
interface at all.
- **Switches in Settings showed no colour when on.** macOS draws its own
switch grey in any window that isn't key, which a menu bar panel often isn't.
They are now drawn by the app and use the system accent colour.
- "Notify on failed runs" had no effect. A newer banner could be dismissed by
the previous banner's timer. `⌘1`–`⌘9` were attached to rows rather than to
the Merge buttons, and every row past the ninth shared `⌘↩`.

### Added

- Launch at login, an app icon (shown in Settings too), and `make install`.
- *Clear* and *Re-run failed jobs* buttons on notifications. A hold's
notification is withdrawn once the hold is gone.
- Reject a held deployment, from a row's right-click menu.
- An optional two-click confirmation for Clear and Merge.
- Rate-limit handling: when GitHub says to stop, Cleared waits until the time
it was given.
- Repositories are rediscovered hourly, so new ones appear without a restart.
- Timestamps in the panel tick while it is open.
- Logging to the unified log under `net.hlnd.Cleared`.
- CI on pull requests, and a script that produces a notarized build.

### Changed

- The token only needs the `repo` scope. Earlier versions asked for `workflow`
as well, which nothing Cleared does requires.
- Failed runs older than 30 days are no longer listed, since GitHub won't
re-run them.
- Release builds use the hardened runtime.

## 0.3.0

Initial public version, plus a fix so the panel sizes to its queue.
6 changes: 6 additions & 0 deletions Cleared.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Build settings shared by every configuration.
//
// Local.xcconfig is optional and gitignored. The `?` makes the include a
// no-op when the file is absent, so a fresh clone builds with ad-hoc signing
// and nothing to set up. See Local.xcconfig.example for what it can override.
#include? "Local.xcconfig"
34 changes: 28 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
.PHONY: generate build test run preview release scan clean
.PHONY: generate build test run install preview icon release dist scan clean

RELEASE_APP := build/Build/Products/Release/Cleared.app
INSTALL_DIR ?= /Applications

generate: ## Regenerate Cleared.xcodeproj from project.yml
xcodegen generate
Expand All @@ -14,19 +17,38 @@ release: generate
-derivedDataPath build clean build

run: release
open build/Build/Products/Release/Cleared.app
open $(RELEASE_APP)

## Build Release, replace the copy in /Applications, and launch it.
## Launch at login only works from an installed copy.
install: release
@pkill -x Cleared 2>/dev/null || true
rm -rf "$(INSTALL_DIR)/Cleared.app"
ditto $(RELEASE_APP) "$(INSTALL_DIR)/Cleared.app"
open "$(INSTALL_DIR)/Cleared.app"
@echo "✓ Installed Cleared $$(defaults read "$(INSTALL_DIR)/Cleared.app/Contents/Info" CFBundleShortVersionString) to $(INSTALL_DIR)"

## Render the panel in a normal window against sample data.
## FIXTURE=full|overflow|empty
## Opens a second instance and only ever closes an earlier preview, so an
## installed copy that is running keeps watching.
preview: build
@pkill -f 'Cleared.app/Contents/MacOS/Cleared' 2>/dev/null || true
@open "$$(xcodebuild -project Cleared.xcodeproj -scheme Cleared -configuration Debug \
-showBuildSettings 2>/dev/null | awk '/BUILT_PRODUCTS_DIR/{print $$3}')/Cleared.app" \
@pkill -f 'Cleared.app/Contents/MacOS/Cleared --ui-preview' 2>/dev/null || true
@open -n "$$(xcodebuild -project Cleared.xcodeproj -scheme Cleared -configuration Debug \
-showBuildSettings 2>/dev/null | sed -n 's/^ *BUILT_PRODUCTS_DIR = //p')/Cleared.app" \
--args --ui-preview $${FIXTURE:-full}

## Redraw the app icon at every size from Scripts/make-icon.swift.
icon:
swift Scripts/make-icon.swift

## Build a Developer ID-signed, notarized zip in dist/. See Scripts/release.sh.
dist:
@./Scripts/release.sh

## Fail loudly if anything token-shaped is about to be committed.
scan:
@./Scripts/scan-secrets.sh

clean:
rm -rf build DerivedData Cleared.xcodeproj
rm -rf build dist DerivedData Cleared.xcodeproj
125 changes: 106 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ The queue is sorted by what blocks you, not by repository. A held production
deploy is the top item no matter which repo it's in, because that's the one
that costs real time.

Every button is one GitHub would honour. A pull request only gets **Merge** when
its checks are green *and* GitHub reports no review or branch rule in the way,
and the merge is pinned to the commit Cleared last saw — so a push that lands
between polls is refused, not merged unseen. **Start a workflow** lists only
workflows that declare `workflow_dispatch`, with the inputs each one's file
declares.

## Why it exists

Environment gates with required reviewers don't fail — they idle. A deploy sits
Expand All @@ -31,28 +38,35 @@ Cleared puts that queue in the menu bar and makes clearing it one click.

## Install

Requires macOS 14+ and Xcode 16+ to build.
Requires macOS 14+, and Xcode 16+ with [XcodeGen](https://github.com/yonaskolb/XcodeGen)
(`brew install xcodegen`) to build. There is no signed download yet, so for now
Cleared is built from source:

```sh
git clone https://github.com/hollandcloud/cleared.git
cd cleared
make run # builds Release and launches it
make install # builds Release, copies it to /Applications, launches it
```

Then turn on *Settings → Launch at login*. Cleared starts watching as soon as it
launches — you don't need to open the panel for holds to be noticed.

`make run` builds and launches without installing, which is fine for a look
but can't be registered to launch at login.

Or open it in Xcode:

```sh
make generate # writes Cleared.xcodeproj from project.yml
open Cleared.xcodeproj
```

`Cleared.xcodeproj` is generated by [XcodeGen](https://github.com/yonaskolb/XcodeGen)
(`brew install xcodegen`) and is deliberately not committed — `project.yml` is
the source of truth.
`Cleared.xcodeproj` is generated and deliberately not committed — `project.yml`
is the source of truth.

## Connecting

Cleared needs a GitHub token with the **`repo`** and **`workflow`** scopes.
Cleared needs a GitHub token with the **`repo`** scope.

- **If you use the `gh` CLI:** click *Use my gh CLI login*. Cleared shells out to
`gh auth token` once and stores the result.
Expand All @@ -64,15 +78,43 @@ in the clear, and never logged — the only representation Cleared will ever
display is a fingerprint like `ghp_…2345 · 40 chars`.

To revoke: *Settings → Disconnect and forget token*, which deletes the Keychain
item. Revoke the token itself on GitHub.
item. Revoke the token itself on GitHub. If GitHub starts rejecting the token —
revoked, or expired — Cleared stops polling and returns to the connect screen
saying so, rather than failing quietly.

### Why `repo`

Approving a deployment, merging, re-running jobs and dispatching a workflow all
require `repo` on a classic token; GitHub offers nothing narrower. (`workflow`
is not needed — that scope is for editing workflow files, which Cleared never
does.)

### Why `repo` and `workflow`
If that's more access than you want a menu bar app to hold, use a fine-grained
token limited to the repositories you want Cleared to touch, with these
repository permissions:

`workflow` is what allows approving a pending deployment and dispatching a
workflow; `repo` covers reading private repositories and merging. GitHub does
not offer a narrower scope for either action. If that's more access than you
want a menu bar app to hold, use a fine-grained PAT limited to the specific
repositories you want Cleared to touch.
| Permission | Access | Used for |
|---|---|---|
| Actions | Read and write | Listing runs, re-running jobs, starting workflows |
| Deployments | Read and write | Approving or rejecting a held deployment |
| Contents | Read and write | Merging; reading workflow files for their inputs |
| Pull requests | Read | Listing your open pull requests |

Organizations that enforce SAML single sign-on also need the token authorized
for that organization on GitHub.

## Using it

- **Click a row** to open it on GitHub. **Right-click** for *Copy Link* — and,
on a held deployment, *Reject…*, which cancels the waiting jobs with a reason.
- **⌘1–⌘9** merge the first nine ready pull requests, in the order shown.
**⌘R** refreshes, **⌘,** opens Settings, **⌘Q** quits.
- A **notification** appears when a deployment starts holding, with a *Clear*
button on it. It is withdrawn once the hold is gone, whoever cleared it.
- *Settings → Ask before clearing or merging* turns each button into a
two-click *Sure?* confirmation. It is off by default: one click is the point.
- Pull requests that can't be merged yet sit under **In flight** with the
reason — *needs review*, *checks running*, *behind base branch*, *conflicts*.

## How it stays inside the rate limit

Expand All @@ -87,14 +129,20 @@ its own. Cleared avoids that:
across every repository, rather than one REST call per repo.
- **Backoff.** Polling slows by 4× while the menu is closed, and by a further
4× when fewer than 500 calls remain.
- **Stopping when told to.** If GitHub answers with a rate limit, Cleared sends
nothing further on that budget until the time GitHub named, instead of
retrying into it.

Remaining budget is shown in the footer.
Remaining budget is shown in the footer. When a poll comes back short — no
network, rate limited — the footer says why and how stale the queue is.

## Repository discovery

Cleared ships with **no repository list**. On first connect it asks GitHub for
repositories you've pushed to in the last 90 days (configurable) and watches
the 20 most recent. You can exclude any of them in Settings.
the 20 most recent, re-checking hourly so a new repository is picked up without
a restart. In Settings you can ignore any of them (and un-ignore them later), or
pin a repository so it stays watched however long it has been quiet.

This is deliberate: it means the source of this public repository reveals
nothing about which repositories anyone actually tracks, and low-traffic repos
Expand All @@ -104,14 +152,35 @@ that see one PR a month get picked up without being added by hand.

```sh
make build # debug build
make test # 17 tests, 8 suites
make test # unit tests
make install # Release build into /Applications
make preview # render the panel against sample data
make preview FIXTURE=overflow # ... with enough rows to scroll
make preview FIXTURE=empty # ... the all-clear state
make icon # redraw the app icon from Scripts/make-icon.swift
make scan # refuse to commit anything token-shaped
make clean
```

Pull requests run the same `make scan`, `make test` and a Release build on
macOS 15 and macOS 26.

### Tests

The tests never reach GitHub and never touch your Keychain. `StubGitHub`
stands in for the API, so the client's caching and rate-limit handling and the
model's behaviour — a revoked token, a laptop going offline, a merge GitHub
refuses — are exercised against scripted responses. This matters more than
usual here: the tests run hosted inside the app, so a test that signed out for
real would delete the token of whoever ran it. Token storage is injected for
exactly that reason.

To watch what the running app is doing:

```sh
log stream --predicate 'subsystem == "net.hlnd.Cleared"' --level debug
```

### Working on the panel

Laying out the queue against live GitHub means waiting for a deployment to
Expand All @@ -130,18 +199,36 @@ that case rather than pretending no token was ever saved.

To make the signature stable, copy `Local.xcconfig.example` to `Local.xcconfig`
(gitignored) and set an identity from `security find-identity -v -p codesigning`.
The file is optional: without it the build falls back to ad-hoc signing.

Install the pre-commit secret scan with `./Scripts/install-hooks.sh`.

### Releasing

A build that other people can download has to be signed with a Developer ID
and notarized, or Gatekeeper refuses to open it:

```sh
DEVELOPER_ID="Developer ID Application: Your Name (TEAMID)" \
NOTARY_PROFILE=cleared-notary \
make dist # writes dist/Cleared-<version>.zip
```

`NOTARY_PROFILE` is a credential profile saved once with
`xcrun notarytool store-credentials`. The script signs with the hardened
runtime, submits to Apple, staples the ticket and asks Gatekeeper to confirm
before it reports success.

### Layout

```
Sources/Cleared/
├── Auth/ Keychain wrapper, token store, gh CLI import
├── GitHub/ Actor-isolated client (ETag cache, rate limits) + the four verbs
├── GitHub/ Actor-isolated client (ETag cache, rate limits), the four
│ verbs, and the reader for workflow_dispatch inputs
├── Models/ Sendable value types
├── Queue/ AppModel — polling lanes, actions, banner state
├── Support/ Settings, notifications
├── Queue/ AppModel — the polling loop, actions, banner state
├── Support/ Settings, notifications, launch at login, logging
└── UI/ MenuBarExtra panel
```

Expand Down
Loading
Loading