diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 5d5a35d..a4a8ab7 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -8,7 +8,7 @@ file="$1" subject="$(sed -n '/^[^#]/{p;q;}' "$file")" if ! printf '%s\n' "$subject" | grep -Eq '^(feat|fix|chore|docs|perf|refactor|test|build|ci|style|revert)(\([a-z0-9._/-]+\))?!?: [^ ].*[^.]$'; then - echo "commit-msg: subject must be a Conventional Commit, e.g. 'fix(cpu): handle tick wraparound'" >&2 + echo "commit-msg: subject must be a Conventional Commit, e.g. 'fix(menu): keep the item on screen'" >&2 echo "commit-msg: got: $subject" >&2 exit 1 fi diff --git a/.githooks/pre-push b/.githooks/pre-push index 2712da7..116bd3c 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -8,12 +8,18 @@ remote="$1" default="$(git symbolic-ref --quiet "refs/remotes/$remote/HEAD" 2>/dev/null | sed "s|^refs/remotes/$remote/||")" || default="" [ -n "$default" ] || default="$(git config init.defaultBranch || echo main)" +branches=0 while read -r _local_ref _local_sha remote_ref _remote_sha; do if [ "$remote_ref" = "refs/heads/$default" ] && git ls-remote --exit-code --heads "$remote" "$default" >/dev/null 2>&1; then echo "pre-push: refusing to push to $default; open a pull request (scripts/agent pr)" >&2 exit 1 fi + case "$remote_ref" in refs/heads/*) branches=$((branches + 1)) ;; esac done +# A tag points at a merge commit CI already checked; checking the working tree again would test +# whatever happens to be checked out, not what's tagged. +[ "$branches" -gt 0 ] || exit 0 + echo "pre-push: full check" exec scripts/task check diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f917d8e..c26fc22 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,10 +8,13 @@ ## Look at sceptically - + ## Checklist - [ ] `scripts/task check` passes locally - [ ] Bug fixes include the test that would have caught them +- [ ] User-facing changes are under `## [Unreleased]` in `CHANGELOG.md` + - [ ] UI changes include before/after screenshots + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 667faa5..86ae561 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,4 @@ -# Common Command has no package dependencies, so GitHub Actions is the only ecosystem to watch. +# No Swift package dependencies, so GitHub Actions is the only ecosystem to watch. version: 2 updates: - package-ecosystem: github-actions diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml new file mode 100644 index 0000000..901bd40 --- /dev/null +++ b/.github/workflows/dependabot.yml @@ -0,0 +1,23 @@ +name: Dependabot + +on: pull_request + +permissions: + contents: read + +jobs: + auto-merge: + # Minor and patch updates merge themselves once the required check passes; majors wait for a person. + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - id: meta + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + - if: steps.meta.outputs.update-type != 'version-update:semver-major' + run: gh pr merge --auto --squash "$PR" + env: + PR: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc06544..8f82bc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,17 +14,19 @@ jobs: contents: write steps: - uses: actions/checkout@v4 - - run: scripts/task check - - name: Package + - name: Check the tag matches the app's version run: | - # ditto keeps the bundle's signature and extended attributes intact. - # The asset name carries no version, so releases/latest/download/CommonCommand.zip - # always points at the newest build. - ditto -c -k --keepParent "build/Common Command.app" CommonCommand.zip - shasum -a 256 CommonCommand.zip > CommonCommand.zip.sha256 + version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist)" + [ "v$version" = "$GITHUB_REF_NAME" ] || { echo "Info.plist says $version, the tag is $GITHUB_REF_NAME"; exit 1; } + - run: scripts/task check + - run: scripts/task package - name: Publish env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "Common Command ${GITHUB_REF_NAME#v}" \ - CommonCommand.zip CommonCommand.zip.sha256 + version="${GITHUB_REF_NAME#v}" + name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' Resources/Info.plist)" + # The notes are this version's CHANGELOG section, the same text the site shows. + awk -v v="$version" 'index($0, "## [" v "]") == 1 { on = 1; next } on && /^## / { exit } on && /^\[/ { exit } on { print }' CHANGELOG.md > notes.md + [ -s notes.md ] || { echo "CHANGELOG.md has no section for $version"; exit 1; } + gh release create "$GITHUB_REF_NAME" --verify-tag --title "$name $version" --notes-file notes.md dist/* diff --git a/.gitignore b/.gitignore index e5248b4..83e7176 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .build/ +dist/ build/ *.xcodeproj .swiftpm/ diff --git a/CLAUDE.md b/CLAUDE.md index 7ad84ed..bfa7ab2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,27 +1,33 @@ # Working in this repository + Common Command is a native macOS launcher, a Spotlight replacement, written in Swift (AppKit plus SwiftUI) with no package dependencies. It is part of the Common Tools family with Common Stats, Common Keys, Common Bar, and Common Glyph. + ## The seam Do all automation through `scripts/task`. CI and the git hooks call nothing else. ```sh -scripts/task fmt # format in place -scripts/task fmt:check # swift format lint --strict, format only -scripts/task lint # fmt:check, then a debug build with warnings as errors -scripts/task test # unit and performance tests -scripts/task build # universal build/Common Command.app -scripts/task check # lint + test + build +scripts/task fmt # format in place +scripts/task fmt:check # swift format lint --strict +scripts/task lint # fmt:check, then a debug build with warnings as errors +scripts/task test [name] # the Swift Testing suites; a name filters them +scripts/task build # universal "build/Common Command.app", signed (see scripts/dev-cert) +scripts/task package # dist/CommonCommand.zip and checksums, as a release publishes them +scripts/task run # build, quit the running copy, open the new one +scripts/task check # lint + test + build: what CI runs ``` ## Workflow - Never commit to `main`. It only moves forward when a pull request is merged; the `pre-push` hook and branch protection both enforce this. -- One unit of work gets one worktree, one branch, and one pull request: `scripts/agent start /`, then work in the printed path, `scripts/agent commit`, `scripts/agent pr`, and `scripts/agent done` after the merge. -- Branch types: `feat fix chore docs perf refactor test`. -- Run `scripts/task check` until it is green before opening a pull request. Never use `--no-verify`. -- In the pull request description, state the problem, the approach, and where a reviewer should be sceptical. +- One unit of work gets one worktree, one branch, and one pull request: `scripts/agent start /`, then work in the printed path and finish with `scripts/agent ship "(scope): subject" "" --body-file `, where the body states the problem, the approach, and where a reviewer should be sceptical. It commits, checks, opens the pull request with auto-merge, keeps it rebased while CI runs, and cleans up after the merge. If it stops on a failed check or a conflict, fix the cause and run it again. +- Branch types: `feat fix chore docs perf refactor test`. The branch's first commit names the pull request and the squash commit on `main`. +- Never use `--no-verify`. +- User-facing changes go under `## [Unreleased]` in `CHANGELOG.md` in the same pull request. The site shows each release's section as "What's new". +- Release with `scripts/release `. Never tag by hand. +- This repository follows the Common Tools app standard (`standard/` in oddurs/common-tools). Change shared files there and apply them to every app; only the regions between `standard:begin` and `standard:end` belong to this app. ## Commits @@ -29,6 +35,7 @@ Conventional Commits: `type(scope): subject`. Write the subject in the imperativ ## Code + - `Sources/CommandKit` holds matching, ranking, usage, the calculator, link detection, and the catalogs, and contains no UI. `Sources/CommonCommand` holds the app shell: hotkey, panel, menu bar item, Settings, and the Spotlight file query. - Speed is the product. Ranking runs on the main thread on every keystroke and must stay well under a millisecond for a real catalog; `PerformanceTests` enforces budgets. Don't loosen a budget to make a change pass. Anything slower (Spotlight, directory scans) runs off the main thread and never blocks the list. - Public APIs only, and no permissions: the hotkey is Carbon's `RegisterEventHotKey`, files come from `MDQuery`, panes from the System Settings extension bundles. Don't add anything that needs Accessibility, Input Monitoring, or Full Disk Access. @@ -36,3 +43,4 @@ Conventional Commits: `type(scope): subject`. Write the subject in the imperativ - A bug fix comes with the test that would have caught it. Ranking changes need a test showing the order they fix. - `CommonTools.swift` is shared word for word across the Common Tools apps. Change it in all of them together. - For visual review, use `swift build && .build/debug/CommonCommand --snapshot out.png "query" [--theme light|dark]` and inspect `out.png`. It renders offscreen; never drive the live screen with synthesized keys or the global shortcut to check a design. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a64a16..d37029f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,26 +6,30 @@ Thanks for helping. Common Command stays small on purpose, so open an issue befo ```sh scripts/setup # wire the git hooks, once +scripts/dev-cert # optional, once: a stable signing identity, so permissions survive rebuilds scripts/agent doctor # check tools and repository state ``` -You need macOS 14 or later and Swift 6 or later. The Command Line Tools are enough. +You need macOS 14.0 or later and Swift 6 or later. The Command Line Tools are enough; Xcode is not required. ## Workflow -Every change goes through a pull request. `main` only moves forward when a pull request is merged; branch protection and the `pre-push` hook both enforce this. +Every change goes through a pull request, and pull requests merge themselves. `main` only moves forward when a pull request is merged; branch protection and the `pre-push` hook both enforce this. Each change gets its own worktree, branch, and pull request: ```sh -scripts/agent start fix/calc-percent-precedence # creates ../.worktrees/common-command/fix/calc-percent-precedence -cd ../.worktrees/common-command/fix/calc-percent-precedence +scripts/agent start fix/short-description # creates ../.worktrees/common-command/fix/short-description +cd ../.worktrees/common-command/fix/short-description # ... make the change ... -scripts/agent commit "fix(calc): apply percent before multiplication" -scripts/agent pr # runs the full check, pushes, opens the PR -scripts/agent done # after merge: removes the worktree and branch +scripts/task run # build it and open it +scripts/agent ship "fix(scope): say what changed" "Why it changed." --body-file pr.md ``` +`ship` commits, runs the full check, opens the pull request with auto-merge, and waits. The pull request squash-merges itself once the `required` check passes. While it waits, `ship` rebases onto `main` whenever `main` moves and checks again. If a check fails or the rebase conflicts, it stops and says so: fix it, then run `scripts/agent ship` again. `commit`, `pr`, and `done` still work one step at a time. + +The pull request takes its title from the branch's first commit, and that title becomes the squash commit on `main`, so make the first commit the one that names the change. The body states the problem, the approach, and where a reviewer should be sceptical. + Branch names are `/`, where type is one of `feat fix chore docs perf refactor test`. ## Commits @@ -37,11 +41,28 @@ Don't add tool or assistant attribution lines (co-author trailers, "generated wi ## Checks ```sh -scripts/task check # lint + test + build, the same thing CI runs +scripts/task check # lint + test + build, the same thing CI runs +scripts/task test # just the tests whose name matches +``` + +A branch must pass this before it becomes a pull request. A bug fix comes with the test that would have caught it. + + +Behaviour that depends on AppKit or Spotlight belongs in the app target; matching, ranking, and parsing belong in CommandKit, where the tests can reach them. + + +## Releases + +```sh +scripts/release 1.2.0 ``` -A branch must pass this before it becomes a pull request. A bug fix comes with the test that would have caught it. Behaviour that depends on AppKit or Spotlight belongs in the app target; matching, ranking, and parsing belong in CommandKit, where the tests can reach them. +Add user-facing changes under `## [Unreleased]` in `CHANGELOG.md` as you make them. `scripts/release` moves them under the new version with today's date, bumps the version in `Resources/Info.plist`, merges that through a pull request, tags the merge, and waits until the download is live. The release notes are that CHANGELOG section. Don't tag by hand. + +## The standard + +Common Command follows the Common Tools app standard, kept in [oddurs/common-tools](https://github.com/oddurs/common-tools/tree/main/standard). The repository files, scripts, and settings are the same in every app. Only the parts between `standard:begin` and `standard:end` markers belong to this app. Change shared files there and apply them to every app, rather than editing one copy. ## Review -This is a solo-maintained project, so branch protection requires zero approvals and the maintainer merges their own pull requests once CI is green. The `required` status check and up-to-date branches are still enforced. +This is a solo-maintained project, so branch protection requires zero approvals, and pull requests merge themselves once the `required` check is green. Branches don't have to be up to date with `main` to merge; `ship` keeps them current instead. diff --git a/README.md b/README.md index 25492b1..0769f22 100644 --- a/README.md +++ b/README.md @@ -93,18 +93,22 @@ mv "build/Common Command.app" /Applications/ ```sh scripts/setup # once: wire the git hooks +scripts/dev-cert # optional, once: a stable signing identity, so permissions survive rebuilds scripts/agent doctor # check tools and repository state ``` | Command | Does | | --- | --- | +| `scripts/task run` | build, quit the running copy, open the new one | | `scripts/task check` | lint + test + build, exactly what CI runs | +| `scripts/task test ` | only the tests whose name matches | | `scripts/agent start /` | new worktree and branch from `origin/main` | -| `scripts/agent commit ": subject"` | Conventional Commit, checked by the hook | -| `scripts/agent pr` | full check, push, open the pull request | +| `scripts/agent ship ": subject"` | commit, check, open the PR with auto-merge, wait for the merge, clean up | +| `scripts/agent pr` | check, push, open the PR with auto-merge, without waiting | | `scripts/agent sync` | rebase onto `origin/main` | -| `scripts/agent done` | after merge: remove the worktree and branch | +| `scripts/agent done` | after a merge: remove the worktree and branch, update the main checkout | | `scripts/agent list` | worktrees, branches, and pull request state | +| `scripts/release ` | a release: dated notes, version bump, tag, published download, checked | See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow. diff --git a/Resources/CommonTools/battery.png b/Resources/CommonTools/battery.png new file mode 100644 index 0000000..3127ec1 Binary files /dev/null and b/Resources/CommonTools/battery.png differ diff --git a/Resources/CommonTools/shot.png b/Resources/CommonTools/shot.png new file mode 100644 index 0000000..dba13d5 Binary files /dev/null and b/Resources/CommonTools/shot.png differ diff --git a/Resources/Info.plist b/Resources/Info.plist index c54968a..3e5a1f9 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -2,6 +2,8 @@ + CFBundleDevelopmentRegion + en CFBundleDisplayName Common Command CFBundleExecutable @@ -10,6 +12,8 @@ AppIcon CFBundleIdentifier com.oddurs.commoncommand + CFBundleInfoDictionaryVersion + 6.0 CFBundleName Common Command CFBundlePackageType @@ -18,6 +22,8 @@ 0.1.0 CFBundleVersion 1 + LSApplicationCategoryType + public.app-category.utilities LSMinimumSystemVersion 14.0 LSUIElement diff --git a/SECURITY.md b/SECURITY.md index 3406e4a..5b1b435 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,4 +10,6 @@ Report it privately through [GitHub Security Advisories](https://github.com/oddu You'll get an acknowledgement within a week. If the report is confirmed, a fix ships in a new release and the advisory is published with credit, unless you'd rather stay anonymous. + Common Command runs unprivileged and asks for no permissions. It makes no network connections of its own; web searches and links open in your browser. It reads the folders apps live in, the System Settings extension bundles, and file names from the Spotlight index of your home folder. It keeps a history of what you opened, and the queries you opened it with, in `~/Library/Application Support/Common Command/usage.json`. Reports about how queries reach Spotlight or how the calculator parses input are especially welcome. + diff --git a/Sources/CommonCommand/CommonTools.swift b/Sources/CommonCommand/CommonTools.swift index bbb726c..22a9c90 100644 --- a/Sources/CommonCommand/CommonTools.swift +++ b/Sources/CommonCommand/CommonTools.swift @@ -1,8 +1,8 @@ import AppKit import SwiftUI -/// The Common Tools family: Common Stats, Common Keys, Common Bar, Common Glyph, and Common Command. This file is -/// identical in every app, so each Settings window ends the same way; change it in all of them together. +/// The Common Tools family. This file is identical in every app, so each Settings window ends the same +/// way. It comes from the app standard in oddurs/common-tools: change it there, not here. struct CommonTool: Identifiable { let id: String let name: String @@ -17,7 +17,7 @@ struct CommonTool: Identifiable { id: "keys", name: "Common Keys", summary: "Fixes words typed on the wrong keyboard", bundleIdentifier: "com.oddurs.langsmart"), CommonTool( - id: "bar", name: "Common Bar", summary: "Hides menu bar icons behind one line", + id: "bar", name: "Common Bar", summary: "Hides the menu bar icons you rarely use", bundleIdentifier: "com.oddurs.reasonal"), CommonTool( id: "glyph", name: "Common Glyph", summary: "Your own image in the menu bar", @@ -25,6 +25,12 @@ struct CommonTool: Identifiable { CommonTool( id: "command", name: "Common Command", summary: "Opens apps, files, and settings from the keyboard", bundleIdentifier: "com.oddurs.commoncommand"), + CommonTool( + id: "shot", name: "Common Shot", summary: "Screenshots straight to the clipboard", + bundleIdentifier: "com.oddurs.commonshot"), + CommonTool( + id: "battery", name: "Common Battery", summary: "The battery in the menu bar, with alerts", + bundleIdentifier: "com.oddurs.commonbattery"), ] static let website = URL(string: "https://oddurs.github.io/common-tools/")! diff --git a/scripts/agent b/scripts/agent index 64d1297..5f444c5 100755 --- a/scripts/agent +++ b/scripts/agent @@ -1,12 +1,16 @@ #!/bin/sh set -eu -# The workflow: one unit of work, one worktree, one branch, one PR. -# Commands: doctor start check commit pr sync done list +# The workflow: one unit of work, one worktree, one branch, one PR, merged automatically. +# Commands: doctor start check commit pr ship sync done list root="$(git rev-parse --show-toplevel)" -repo="$(basename "$(git -C "$root" rev-parse --path-format=absolute --git-common-dir | xargs dirname)")" -worktrees="$(dirname "$(git -C "$root" rev-parse --path-format=absolute --git-common-dir | xargs dirname)")/.worktrees/$repo" +main_checkout="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir | xargs dirname)" +# Worktrees are named after the GitHub repository, not the local folder, so paths match the docs +# on every machine. Without an origin, the folder name will do. +origin_url="$(git -C "$root" remote get-url origin 2>/dev/null || :)" +repo="$(basename "${origin_url:-$main_checkout}" .git)" +worktrees="$(dirname "$main_checkout")/.worktrees/$repo" die() { echo "agent: $*" >&2 @@ -79,10 +83,86 @@ pr) [ "$branch" != "$(default_branch)" ] || die "on the default branch; start a branch with 'scripts/agent start'" echo "==> checking" "$root/scripts/task" check + merged="the pull request for $branch already merged; put new commits on a new branch ('scripts/agent start'), then run 'scripts/agent done' here" + state="$(gh pr view "$branch" --json state --jq .state 2>/dev/null || :)" + case "$state" in + MERGED) die "$merged" ;; + CLOSED) die "the pull request for $branch was closed; reopen it or start a new branch" ;; + OPEN) + # Auto-merge lands the head it already has once checks pass, so it could merge before new + # commits arrive. Hold it while pushing; it's enabled again below, for the new head. + echo "==> pausing auto-merge while pushing" + gh pr merge "$branch" --disable-auto >/dev/null 2>&1 || : + [ "$(gh pr view "$branch" --json state --jq .state)" != "MERGED" ] || die "$merged" + ;; + esac echo "==> pushing $branch" - git push --quiet -u origin "$branch" - echo "==> opening pull request" - gh pr create --fill "$@" + git push --quiet --force-with-lease -u origin "$branch" + if [ "$state" = "OPEN" ]; then + echo "==> pull request already open" + else + echo "==> opening pull request" + # The first commit names the unit of work; later ones are fixes along the way. + gh pr create --fill-first "$@" + fi + # Only ever merge what is checked out here. + head="$(gh pr view "$branch" --json headRefOid --jq .headRefOid)" + [ "$head" = "$(git rev-parse HEAD)" ] || die "the pull request's head is $head, not this branch's HEAD; push again" + title="$(gh pr view "$branch" --json title --jq .title)" + echo "$title" | grep -Eq '^(feat|fix|chore|docs|perf|refactor|test|build|ci|style|revert)(\([a-z0-9._/-]+\))?!?: [^ ].*[^.]$' || + die "pull request title '$title' is not a Conventional Commit; fix it with gh pr edit --title" + number="$(gh pr view "$branch" --json number --jq .number)" + # Squash-merges by itself once the required check passes, under the pull request's title. + echo "==> enabling auto-merge" + gh pr merge "$branch" --auto --squash --subject "$title (#$number)" + gh pr view "$branch" --json url --jq .url + ;; +ship) + # Commit (when given a message), open the pull request with auto-merge, keep it current with the + # default branch while CI runs, and clean up once it merges. Stops on a failed check or a conflict. + branch="$(current_branch)" + base="$(default_branch)" + [ "$branch" != "$base" ] || die "on the default branch; start a branch with 'scripts/agent start'" + # ship ["" [""]] [--body-file ...]: flags go to gh pr create. + if [ $# -ge 1 ] && [ "${1#--}" = "$1" ]; then + subject="$1" + shift + git add -A + if [ $# -ge 1 ] && [ "${1#--}" = "$1" ]; then + git commit --quiet -m "$subject" -m "$1" + shift + else + git commit --quiet -m "$subject" + fi + fi + [ -z "$(git status --porcelain)" ] || die "uncommitted changes; pass a commit message or commit them first" + "$0" pr "$@" + echo "==> waiting for $branch to merge" + while :; do + state="$(gh pr view "$branch" --json state --jq .state)" + [ "$state" = "MERGED" ] && break + [ "$state" = "CLOSED" ] && die "the pull request was closed without merging" + git fetch --quiet origin + if ! git merge-base --is-ancestor "origin/$base" HEAD; then + # Test against what it will land on, not what it branched from. + echo "==> $base moved; rebasing and checking again" + git rebase --quiet "origin/$base" || die "conflicts with $base; resolve them, then run 'scripts/agent ship' again" + "$root/scripts/task" check + # Auto-merge may have landed the pull request while the check ran, deleting the branch. + merged() { [ "$(gh pr view "$branch" --json state --jq .state)" = "MERGED" ]; } + merged && continue + git push --quiet --force-with-lease origin "$branch" || merged || die "could not push $branch" + fi + # Only the checks that gate the merge: auto-merge goes ahead past any other failure. + # gh pr checks exits 1 on a failure, 8 while pending; "no checks reported" means not started yet. + checks=0 + report="$(gh pr checks "$branch" --required 2>&1)" || checks=$? + case "$report" in *"no checks reported"* | *"no required checks reported"*) checks=8 ;; esac + [ "$checks" -eq 1 ] && die "a check failed: $(gh pr view "$branch" --json url --jq .url)/checks" + sleep 15 + done + echo "==> merged" + "$0" done ;; sync) base="$(default_branch)" @@ -104,6 +184,12 @@ done) git -C "$main" branch --quiet -D "$branch" git -C "$main" worktree prune git -C "$main" fetch --quiet --prune origin + # Bring the primary checkout up to date, unless someone is working in it. + if [ "$(git -C "$main" rev-parse --abbrev-ref HEAD)" = "$base" ] && [ -z "$(git -C "$main" status --porcelain)" ]; then + git -C "$main" merge --quiet --ff-only "origin/$base" && echo "==> $main is at origin/$base" + else + echo "==> left $main as it is: not on $base, or it has changes" + fi echo "==> done; cd $main" ;; list) @@ -115,7 +201,7 @@ list) done ;; *) - echo "usage: scripts/agent {doctor|start /|check|commit [body]|pr [--draft]|sync|done|list}" >&2 + echo "usage: scripts/agent {doctor|start /|check|commit [body]|pr [--draft]|ship [ [body]] [--body-file f]|sync|done|list}" >&2 exit 2 ;; esac diff --git a/scripts/dev-cert b/scripts/dev-cert new file mode 100755 index 0000000..a411eff --- /dev/null +++ b/scripts/dev-cert @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu +# Creates a self-signed code signing certificate in the login keychain, once, shared by every +# Common Tools app. +# +# macOS ties permissions such as Accessibility, and login items, to an app's signature. Ad-hoc +# signatures change with every build, so every rebuild loses them. A certificate that stays put +# keeps them. scripts/task build signs with it whenever it's there. + +name="Common Tools Development" +keychain="$HOME/Library/Keychains/login.keychain-db" + +if security find-certificate -c "$name" "$keychain" >/dev/null 2>&1; then + echo "==> '$name' is already in the login keychain; nothing to do" + exit 0 +fi + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +echo "==> generating '$name' (valid ten years)" +# The system LibreSSL writes PKCS#12 files that security import can read; OpenSSL 3 needs -legacy. +/usr/bin/openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -subj "/CN=$name" \ + -addext "basicConstraints=critical,CA:false" \ + -addext "keyUsage=critical,digitalSignature" \ + -addext "extendedKeyUsage=critical,codeSigning" \ + -keyout "$work/key.pem" -out "$work/cert.pem" +/usr/bin/openssl pkcs12 -export -inkey "$work/key.pem" -in "$work/cert.pem" \ + -name "$name" -out "$work/identity.p12" -passout pass:common-tools + +echo "==> importing into the login keychain, usable by codesign" +security import "$work/identity.p12" -k "$keychain" -P common-tools -T /usr/bin/codesign >/dev/null + +echo "==> done. Rebuild with scripts/task build, then grant any permission an app asks for once more." diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift index f1fc577..e2c71f4 100644 --- a/scripts/make-icon.swift +++ b/scripts/make-icon.swift @@ -1,7 +1,7 @@ // Renders Resources/AppIcon.icns and docs/icon.png. Run: swift scripts/make-icon.swift // // The Common Tools icon family: a blue squircle with a frosted glass glyph. Only the glyph differs -// between the apps; keep the rest identical across their repos. +// between Common Stats, Common Keys, and Common Bar; keep the rest identical across the three repos. import AppKit // Shared look: Apple's 824 pt squircle on a 1024 canvas, a blue gradient body, and a frosted @@ -111,6 +111,7 @@ func rounded(_ r: CGRect, _ radius: CGFloat) -> CGPath { // MARK: - Glyph +// standard:begin glyph func glyph(_ ctx: CGContext) { // A search field: one line of glass, and the ⌘ that opens it. let panel = CGRect(x: 212, y: 292, width: 600, height: 440) @@ -129,6 +130,7 @@ func glyph(_ ctx: CGContext) { ctx.fillPath() } } +// standard:end glyph func render(_ px: Int, _ glyph: (CGContext) -> Void) -> Data { let rep = NSBitmapImageRep( @@ -153,8 +155,6 @@ for size in [16, 32, 128, 256, 512] { try render(size * 2, glyph).write(to: iconset.appendingPathComponent("icon_\(size)x\(size)@2x.png")) } try render(256, glyph).write(to: root.appendingPathComponent("docs/icon.png")) -// Every Common Tools app ships each family member's icon at this size for its Settings window. -try render(128, glyph).write(to: root.appendingPathComponent("Resources/CommonTools/command.png")) let iconutil = Process() iconutil.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") iconutil.arguments = ["-c", "icns", iconset.path, "-o", root.appendingPathComponent("Resources/AppIcon.icns").path] diff --git a/scripts/release b/scripts/release new file mode 100755 index 0000000..29ceab2 --- /dev/null +++ b/scripts/release @@ -0,0 +1,99 @@ +#!/bin/sh +set -eu + +# Cuts a release: scripts/release +# +# 1. In a new worktree, moves the CHANGELOG's Unreleased notes under the version with today's date, +# updates the compare links, and bumps the version in Resources/Info.plist. +# 2. Ships that through a pull request, which merges itself (scripts/agent ship). +# 3. Tags the merge commit and waits for the Release workflow to publish it. +# 4. Downloads the published zip and checks it is the new version. + +die() { + echo "release: $*" >&2 + exit 1 +} + +version="${1:-}" +echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || die "usage: scripts/release " + +root="$(git rev-parse --show-toplevel)" +cd "$root" +git fetch --quiet --tags origin +git rev-parse --quiet --verify "refs/tags/v$version" >/dev/null && die "v$version already exists" +previous="$(git tag --list 'v*' --sort=-v:refname | head -n 1)" +url="$(git remote get-url origin | sed -E 's#^git@github.com:#https://github.com/#; s#\.git$##')" +asset="$(sed -n 's/^asset="\(.*\)".*/\1/p' scripts/task)" +[ -n "$asset" ] || die "scripts/task has no asset= line" + +# Refuse an empty release: the notes are what the site and the release page show. +notes="$(git show origin/main:CHANGELOG.md | awk ' + index($0, "## [Unreleased]") == 1 { on = 1; next } + on && (/^## / || /^\[/) { exit } + on && /^- / { n++ } + END { print n + 0 }')" +[ "$notes" -gt 0 ] || die "nothing under ## [Unreleased] in CHANGELOG.md on main; write the release notes first" + +echo "==> preparing $version (previous: ${previous:-none})" +path="$("$root/scripts/agent" start "chore/release-$version" | sed -n 's/^cd //p')" +cd "$path" +awk -v v="$version" -v d="$(date +%Y-%m-%d)" -v url="$url" -v prev="$previous" ' + index($0, "## [Unreleased]") == 1 { print; print ""; print "## [" v "] - " d; next } + index($0, "[Unreleased]: ") == 1 { + print "[Unreleased]: " url "/compare/v" v "...HEAD" + if (prev != "") print "[" v "]: " url "/compare/" prev "...v" v + else print "[" v "]: " url "/releases/tag/v" v + next + } + { print }' CHANGELOG.md >CHANGELOG.md.new +mv CHANGELOG.md.new CHANGELOG.md +plist=Resources/Info.plist +build="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$plist")" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $version" -c "Set :CFBundleVersion $((build + 1))" "$plist" +name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' "$plist")" + +body="$(mktemp)" +trap 'rm -f "$body"' EXIT +cat >"$body" < tagging $sha as v$version" +git fetch --quiet origin +git tag -a "v$version" "$sha" -m "$name $version" +git push --quiet origin "v$version" + +echo "==> waiting for the Release workflow" +run="" +for _ in $(seq 1 30); do + run="$(gh run list --workflow Release --branch "v$version" --limit 1 --json databaseId --jq '.[0].databaseId // empty')" + [ -n "$run" ] && break + sleep 5 +done +[ -n "$run" ] || die "the Release workflow didn't start for v$version" +gh run watch "$run" --exit-status >/dev/null || die "the Release workflow failed: $url/actions/runs/$run" + +echo "==> checking the published download" +check="$(mktemp -d)" +trap 'rm -rf "$check" "$body"' EXIT +curl -fsSL -o "$check/$asset.zip" "$url/releases/latest/download/$asset.zip" +(cd "$check" && ditto -x -k "$asset.zip" .) +published="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$check/$name.app/Contents/Info.plist")" +[ "$published" = "$version" ] || die "the latest download is $published, not $version" +echo "==> released $name $version: $url/releases/tag/v$version" diff --git a/scripts/task b/scripts/task index 385d8f7..bc8352b 100755 --- a/scripts/task +++ b/scripts/task @@ -1,10 +1,24 @@ #!/bin/sh set -eu -# The one seam CI and git hooks call. Verbs: fmt fmt:check lint test build check +# The one seam CI and git hooks call. Verbs: fmt fmt:check lint test build check, plus package and +# run. This file is the same in every Common Tools app; the values below come from +# standard/apps.json in oddurs/common-tools. cd "$(dirname "$0")/.." +name="Common Command" +executable="CommonCommand" +bundle_id="com.oddurs.commoncommand" +asset="CommonCommand" +# Command-line tools built and shipped beside the app. +tools="" + +sources="Package.swift Sources Tests scripts/make-icon.swift" +app="build/$name.app" +# The deployment target is written once, in Info.plist; the build follows it. +min_macos="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' Resources/Info.plist)" + # Command Line Tools ship Swift Testing outside the default search paths; # Xcode does not need this. test_flags() { @@ -19,7 +33,19 @@ test_flags() { esac } -sources="Package.swift Sources Tests scripts/make-icon.swift" +# macOS ties permissions such as Accessibility, and login items, to the signature. A stable +# certificate keeps them across rebuilds; an ad-hoc signature changes every build. scripts/dev-cert +# makes one, shared by every Common Tools app. +sign() { + identity="Common Tools Development" + if security find-certificate -c "$identity" >/dev/null 2>&1; then + echo "==> signing $1 with '$identity'" + codesign --force --sign "$identity" --identifier "$2" --options runtime "$1" + else + echo "==> signing $1 ad-hoc; run scripts/dev-cert to keep permissions across rebuilds" + codesign --force --sign - --identifier "$2" --options runtime "$1" + fi +} case "${1:-}" in fmt) @@ -38,32 +64,62 @@ lint) swift build -Xswiftc -warnings-as-errors ;; test) - echo "==> running tests" + echo "==> running tests${2:+ matching $2}" # shellcheck disable=SC2046 - swift test $(test_flags) + swift test $(test_flags) ${2:+--filter "$2"} ;; build) # One build per architecture, joined with lipo: `--arch a --arch b` # needs Xcode's build system, which Command Line Tools lack. - bins="" + dirs="" for arch in arm64 x86_64; do + triple="$arch-apple-macosx$min_macos" echo "==> building release for $arch" - swift build -c release --triple "$arch-apple-macosx14.0" - bins="$bins $(swift build -c release --triple "$arch-apple-macosx14.0" --show-bin-path)/CommonCommand" + swift build -c release --triple "$triple" + dirs="$dirs $(swift build -c release --triple "$triple" --show-bin-path)" done - app="build/Common Command.app" echo "==> assembling $app" rm -rf "$app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + # The bundle carries the product name; the executable keeps the target's name. # shellcheck disable=SC2086 - lipo -create $bins -output "$app/Contents/MacOS/CommonCommand" + lipo -create $(for dir in $dirs; do echo "$dir/$executable"; done) -output "$app/Contents/MacOS/$executable" cp Resources/Info.plist "$app/Contents/" cp Resources/AppIcon.icns "$app/Contents/Resources/" - # The Common Tools section of Settings shows every family member's icon from here. + # The Common Tools section of Settings shows every app's icon from here. cp -R Resources/CommonTools "$app/Contents/Resources/" - # Ad-hoc signature: enough for Gatekeeper on the building machine and for SMAppService. - codesign --force --sign - --options runtime "$app" + sign "$app" "$bundle_id" echo "==> built $app" + for tool in $tools; do + # shellcheck disable=SC2046 + lipo -create $(for dir in $dirs; do echo "$dir/$tool"; done) -output "build/$tool" + sign "build/$tool" "$tool" + echo "==> built build/$tool" + done + ;; +package) + # What a release publishes. The names carry no version, so + # releases/latest/download/.zip always gets the newest. + [ -d "$app" ] || "$0" build + echo "==> packaging into dist/" + rm -rf dist + mkdir dist + # ditto keeps the bundle's signature and extended attributes intact. + ditto -c -k --keepParent "$app" "dist/$asset.zip" + for tool in $tools; do ditto -c -k "build/$tool" "dist/$tool.zip"; done + for zip in dist/*.zip; do (cd dist && shasum -a 256 "${zip#dist/}" >"${zip#dist/}.sha256"); done + ls -l dist + ;; +run) + "$0" build + echo "==> relaunching $name" + # By process name: a fresh build isn't registered with Launch Services yet, so AppleScript + # can't always find it by bundle identifier. + if pgrep -x "$executable" >/dev/null; then + pkill -x "$executable" + while pgrep -x "$executable" >/dev/null; do sleep 0.2; done + fi + open "$app" ;; check) "$0" lint @@ -71,7 +127,7 @@ check) "$0" build ;; *) - echo "usage: scripts/task {fmt|fmt:check|lint|test|build|check}" >&2 + echo "usage: scripts/task {fmt|fmt:check|lint|test [name]|build|package|run|check}" >&2 exit 2 ;; esac