From ca30c9368e299342668a7bc830de22edb9f929f1 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Mon, 7 Sep 2026 10:02:47 +0800 Subject: [PATCH 1/4] packaging: make the Homebrew submission automatic by being the tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last manual step in a release was "copy packaging/homebrew/donottype.rb into the tap's Casks/ and push". It was documented as unavoidable because a submission is a pull request to somebody else's repository — but that was only true of homebrew-cask, which this project has not applied to. The tap it actually told people to install from, bojieli/tap, did not exist, so the cask's own install line had never worked. So this repository becomes the tap. `Casks/` is one of the three directories Homebrew looks in, which makes the file somebody installs the file reviewed here: one copy, no submission, nothing to forget. brew tap bojieli/donottype https://github.com/bojieli/DoNotType brew install --cask donottype The URL is needed because a tap is normally located by the name `homebrew-` and this repository is named for the product. That is the entire cost, and it buys away a second repository, a cross-repository token, and a copy that can go stale between releases. The new packaging.yml does the bump on `release: published` and commits it. That trigger rather than the tag, because the cask cannot be correct any earlier: update-packaging.sh reads the checksum from the release's own .sha256 asset, which 404s while the release is still a draft. It is also the moment the artifacts become real, so it is when the tap should follow them. The rolling `latest` prerelease is excluded — every green build on main republishes it, and a cask following that would hand users a development build. Kept separate from release.yml rather than bolted onto it: that workflow is tag-driven and ends by drafting a release for a human to read, and adding a release trigger there would have meant guarding every existing job against an event none of them want. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/packaging.yml | 107 +++++++++++++++++++++ {packaging/homebrew => Casks}/donottype.rb | 22 +++-- README.md | 13 ++- docs/RELEASING.md | 55 ++++++++--- scripts/update-packaging.sh | 11 ++- 5 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/packaging.yml rename {packaging/homebrew => Casks}/donottype.rb (57%) diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml new file mode 100644 index 0000000..f4ce815 --- /dev/null +++ b/.github/workflows/packaging.yml @@ -0,0 +1,107 @@ +name: Packaging + +# Points the Homebrew cask at a release, automatically, the moment that release is published. +# +# Separate from release.yml on purpose. That workflow is tag-driven and ends by drafting a release +# for a human to read; this one starts where a human finishes, because the cask cannot be correct +# any earlier: scripts/update-packaging.sh reads the checksum from the release's own `.sha256` +# asset URL, which 404s while the release is still a draft. Publication is also the moment the +# artifacts become real for users, so it is the right moment for the tap to follow them. +# +# This repository is its own tap — the cask lives at Casks/donottype.rb — so "submitting" it is a +# commit to the default branch and nothing else. There is no second repository, no token beyond the +# built-in one, and no copy of the file to fall out of date. +on: + release: + types: [published] + # A hand re-run, for a release published before this workflow existed or one whose run failed. + workflow_dispatch: + inputs: + version: + description: 'Version to point the cask at (x.y.z, no leading v)' + type: string + required: true + +permissions: + contents: read + +# Two releases published within a minute of each other would otherwise race to push the same file, +# and the loser would fail on a non-fast-forward rather than simply going second. +concurrency: + group: packaging + cancel-in-progress: false + +jobs: + homebrew: + name: Point the cask at the release + # The rolling `latest` prerelease is republished by every green build on main. A cask that + # followed it would quietly hand users a development build, so only versioned, non-prerelease + # tags are allowed to move the tap. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.release.prerelease == false && + startsWith(github.event.release.tag_name, 'v')) + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The default branch, not the tag. The cask somebody installs is the one on the branch + # this repository is tapped at; updating a detached checkout of the tag would change a + # file nobody reads. + ref: ${{ github.event.repository.default_branch }} + + # Through the environment rather than interpolated into the script body: a tag name is + # attacker-controllable on a fork, and `${{ }}` inside `run:` is textual substitution. + - name: Resolve the version + id: resolve + env: + INPUT_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + version="${INPUT_VERSION:-$RELEASE_TAG}" + version="${version#v}" + if ! [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "✗ '$version' is not a canonical x.y.z version" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Fails loudly when the release has no published macOS checksum, which is the one thing that + # would otherwise produce a cask pointing at a download nobody can verify. + - name: Point the cask at it + env: + VERSION: ${{ steps.resolve.outputs.version }} + run: ./scripts/update-packaging.sh "$VERSION" + + - name: Commit it to the tap + env: + VERSION: ${{ steps.resolve.outputs.version }} + BRANCH: ${{ github.event.repository.default_branch }} + run: | + if git diff --quiet -- Casks/; then + echo "The cask already points at $VERSION — nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Casks/donottype.rb + git commit -m "packaging: point the tap at v$VERSION" \ + -m "Written by scripts/update-packaging.sh from the published release's own .sha256, so no checksum is typed by hand. This repository is its own Homebrew tap, so this commit is the submission." + + # main forbids non-fast-forward pushes, so losing a race is a failed push rather than a + # clobbered branch. Rebase and try again instead of failing a release over ordering. + for attempt in 1 2 3; do + if git push origin "HEAD:$BRANCH"; then + echo "pushed on attempt $attempt" + exit 0 + fi + git fetch origin "$BRANCH" + git rebase "origin/$BRANCH" || { + echo "✗ the cask changed underneath this run; re-run this workflow" >&2 + exit 1 + } + done + echo "✗ could not push after 3 attempts" >&2 + exit 1 diff --git a/packaging/homebrew/donottype.rb b/Casks/donottype.rb similarity index 57% rename from packaging/homebrew/donottype.rb rename to Casks/donottype.rb index ff5919b..7b816dc 100644 --- a/packaging/homebrew/donottype.rb +++ b/Casks/donottype.rb @@ -1,14 +1,22 @@ # Homebrew cask for DoNotType. # -# Not submitted to homebrew-cask yet: registry onboarding should follow a notarized release with -# some public history. Until then this file lives here so it can be installed from a tap, and so the -# shape of the thing is reviewable rather than invented at submission time: +# This repository *is* the tap. `Casks/` is one of the three directories Homebrew looks in, so the +# file installed from is the file reviewed in this pull request — there is no second copy to fall +# out of date, and no submission step to forget: # -# brew install --cask bojieli/tap/donottype +# brew tap bojieli/donottype https://github.com/bojieli/DoNotType +# brew install --cask donottype # -# `scripts/update-packaging.sh ` fills in the version and the checksum from a published -# release, so nobody hand-copies a sha256 — a cask with a stale hash fails at install time with a -# message about a corrupt download, which is a bad way to learn that a field was forgotten. +# The URL is required because a tap is normally found by the name `homebrew-`, and this repo is +# named for the product rather than for Homebrew. That is the whole cost of keeping one copy. +# +# Not submitted to homebrew-cask itself yet: registry onboarding should follow a notarized release +# with some public history. Until then this is the supported way to install. +# +# The version and checksum below are written by `scripts/update-packaging.sh `, which the +# release workflow runs for itself when a release is published — nobody hand-copies a sha256, and a +# cask with a stale hash fails at install time complaining about a corrupt download, which is a bad +# way to learn that a field was forgotten. cask "donottype" do version "0.6.2" sha256 "56a017fe48be8e459156050c3dce8f5fbed38524e9be90aa9c025aefe2862493" diff --git a/README.md b/README.md index 3c9b031..e855925 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,18 @@ as source but is deliberately not distributed until its production build is manu Authenticode signing is available. The rolling Android build uses a debug key; versioned Android releases use the configured release keystore. -To build and install the macOS app: +On macOS, Homebrew installs the same notarized build and keeps it updated: + +```bash +brew tap bojieli/donottype https://github.com/bojieli/DoNotType +brew install --cask donottype +``` + +The repository is its own tap, so the cask you install is the one in +[`Casks/donottype.rb`](Casks/donottype.rb); the URL is needed only because the repository is named +for the product rather than `homebrew-donottype`. + +To build and install the macOS app from source instead: ```bash git clone https://github.com/bojieli/DoNotType diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 37059b7..6d4529b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -95,29 +95,52 @@ Normal main-branch CI still runs the full iOS suite as regression coverage. ## Package managers -Manifests live in [`packaging/`](../packaging/) and are **not** submitted yet. Homebrew onboarding -should follow a notarized macOS release with some public history. The winget drafts remain dormant: -there is no Windows release artifact for them to reference until Windows production verification -and Authenticode signing exist. +### Homebrew — automatic -After a release is published: +**This repository is its own tap.** The cask lives at [`Casks/donottype.rb`](../Casks/donottype.rb), +which is one of the three directories Homebrew looks in, so the file somebody installs from is the +file reviewed in a pull request here. There is no second repository, no copy to fall out of date, +and no submission step to forget. ```bash -./scripts/update-packaging.sh 0.2.0 +brew tap bojieli/donottype https://github.com/bojieli/DoNotType +brew install --cask donottype ``` -The script reads the macOS `.sha256` file the workflow published and writes the version and checksum -into the Homebrew cask. No hash is typed by hand: a cask with a stale checksum fails at install with -a complaint about a corrupt download, which reads as something far more alarming than a forgotten -field. It deliberately does not update the dormant winget drafts. +The URL is needed because a tap is normally located by the name `homebrew-`, and this repository +is named for the product. That is the entire cost of keeping one copy of the cask. -Submission is deliberately manual, because each submission is a pull request to somebody else's -repository: +**Publishing a release updates it.** [`packaging.yml`](../.github/workflows/packaging.yml) runs on +`release: published`, points the cask at that version with `scripts/update-packaging.sh`, and +commits the result to the default branch. Nothing to run, and nothing to remember. -| | where | -|---|---| -| Homebrew | copy `packaging/homebrew/donottype.rb` into the tap's `Casks/` | -| winget | unavailable until a verified, Authenticode-signed Windows artifact is restored | +It runs on publication rather than on the tag because the cask cannot be correct any earlier: the +script reads the checksum from the release's own `.sha256` asset, which 404s while the release is +still a draft. That is also the moment the artifacts become real for users, so it is the right +moment for the tap to follow them. The rolling `latest` prerelease is excluded — every green build +on main republishes it, and a cask following that would hand users a development build. + +No hash is ever typed by hand. A cask with a stale checksum fails at install with a complaint about +a corrupt download, which reads as something far more alarming than a forgotten field. + +To re-point the cask outside that flow — a release published before this workflow existed, or a run +that failed — run the workflow by hand from the Actions tab with a version, or locally: + +```bash +./scripts/update-packaging.sh 0.6.2 && git commit -am "packaging: point the tap at v0.6.2" +``` + +### winget — dormant + +The drafts in [`packaging/winget/`](../packaging/winget/) are **not** submitted and are not touched +by the script or the workflow. There is no Windows release artifact for them to reference until +Windows production verification and Authenticode signing exist. + +### homebrew-cask — not yet + +Onboarding to Homebrew's own registry should follow a notarized macOS release with some public +history, and it is a pull request to somebody else's repository, so it stays a decision rather than +a step. The tap above is the supported way to install in the meantime. ## Signing diff --git a/scripts/update-packaging.sh b/scripts/update-packaging.sh index 7273c4c..6c0b166 100755 --- a/scripts/update-packaging.sh +++ b/scripts/update-packaging.sh @@ -2,6 +2,10 @@ # # Fills the version and checksum into the Homebrew cask from a published release. # +# The cask lives at Casks/donottype.rb because this repository is its own Homebrew tap: the file +# somebody installs from is the file in this checkout, so there is no second copy to drift and no +# submission step between a release and the thing users get. +# # Hand-copying a sha256 is the step that goes wrong, and it goes wrong invisibly: a cask with a # stale hash fails at install time complaining about a corrupt download. That reads as something # far more alarming than "somebody forgot to update a field". So nobody types a hash — this reads @@ -55,7 +59,7 @@ import re, sys version, mac_sha = sys.argv[1], sys.argv[2] # Homebrew: version and sha256 are their own lines, so this is unambiguous. -path = "packaging/homebrew/donottype.rb" +path = "Casks/donottype.rb" text = open(path).read() text, version_matches = re.subn( r'version "[^"]*"', f'version "{version}"', text, count=1) @@ -68,5 +72,6 @@ PY echo "✓ packaging updated to $VERSION" echo -echo "Next (manual because it is a submission to another repository):" -echo " Homebrew copy packaging/homebrew/donottype.rb into your tap's Casks/ and push" +echo "Casks/donottype.rb is the tap — committing it to the default branch is the release." +echo "The release workflow does that for itself when a release is published; run this by hand" +echo "only to re-point the cask outside that flow." From f2da26f5007b89e60ca1804c19b17eab92bd65d8 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Mon, 7 Sep 2026 10:03:40 +0800 Subject: [PATCH 2/4] packaging: drop the deprecated verified: parameter from the cask Homebrew warns on it now, on every command a tapped user runs. It was always redundant here: the default check is that the download host matches homepage, and both are github.com/bojieli/DoNotType. Co-Authored-By: Claude Opus 5 (1M context) --- Casks/donottype.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Casks/donottype.rb b/Casks/donottype.rb index 7b816dc..9e2de73 100644 --- a/Casks/donottype.rb +++ b/Casks/donottype.rb @@ -21,8 +21,9 @@ version "0.6.2" sha256 "56a017fe48be8e459156050c3dce8f5fbed38524e9be90aa9c025aefe2862493" - url "https://github.com/bojieli/DoNotType/releases/download/v#{version}/DoNotType-macOS.zip", - verified: "github.com/bojieli/DoNotType/" + # No `verified:` — Homebrew deprecated it, and it was always redundant here: the default check + # is that the download host matches `homepage`, which it does. + url "https://github.com/bojieli/DoNotType/releases/download/v#{version}/DoNotType-macOS.zip" name "DoNotType" desc "Voice input that transcribes what you said instead of rewriting it" homepage "https://github.com/bojieli/DoNotType/" From cd2b67588cdf2ea1825b9d36f228ec391cd3912b Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Mon, 7 Sep 2026 10:05:22 +0800 Subject: [PATCH 3/4] packaging: declare the cask's minimum macOS brew audit caught it: the app's artifact requires Sonoma, matching LSMinimumSystemVersion in Resources/Info.plist and .macOS(.v14) in Package.swift, but the cask promised nothing. Homebrew would install onto an older system and the app would refuse to launch, which is a worse way to learn the requirement than being told before the download starts. Co-Authored-By: Claude Opus 5 (1M context) --- Casks/donottype.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Casks/donottype.rb b/Casks/donottype.rb index 9e2de73..c3d2e05 100644 --- a/Casks/donottype.rb +++ b/Casks/donottype.rb @@ -28,6 +28,11 @@ desc "Voice input that transcribes what you said instead of rewriting it" homepage "https://github.com/bojieli/DoNotType/" + # Matches LSMinimumSystemVersion in Resources/Info.plist and .macOS(.v14) in Package.swift. + # Without it Homebrew installs happily onto an older system and the app refuses to launch, which + # is a worse way to learn the requirement than being told before the download. + depends_on macos: ">= :sonoma" + # Accessibility is revoked whenever the signature changes, so an update always needs re-granting. # Saying so here is cheaper than a support thread about dictation that silently stopped. caveats <<~CAVEATS From 07c98f12b3350b2ee254ab7228a1f4a82f9d60c4 Mon Sep 17 00:00:00 2001 From: Bojie Li Date: Mon, 7 Sep 2026 10:06:27 +0800 Subject: [PATCH 4/4] packaging: use the symbol form for the cask's macOS minimum The string form warned on every command a tapped user ran. Homebrew's own deprecation names the replacement, and macos_requirement.rb defaults the comparator to >=, so the bare symbol is still a floor and not an exact match. Co-Authored-By: Claude Opus 5 (1M context) --- Casks/donottype.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Casks/donottype.rb b/Casks/donottype.rb index c3d2e05..8acdef9 100644 --- a/Casks/donottype.rb +++ b/Casks/donottype.rb @@ -31,7 +31,7 @@ # Matches LSMinimumSystemVersion in Resources/Info.plist and .macOS(.v14) in Package.swift. # Without it Homebrew installs happily onto an older system and the app refuses to launch, which # is a worse way to learn the requirement than being told before the download. - depends_on macos: ">= :sonoma" + depends_on macos: :sonoma # Accessibility is revoked whenever the signature changes, so an update always needs re-granting. # Saying so here is cheaper than a support thread about dictation that silently stopped.