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/Casks/donottype.rb b/Casks/donottype.rb new file mode 100644 index 0000000..8acdef9 --- /dev/null +++ b/Casks/donottype.rb @@ -0,0 +1,56 @@ +# Homebrew cask for DoNotType. +# +# 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 tap bojieli/donottype https://github.com/bojieli/DoNotType +# brew install --cask donottype +# +# 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" + + # 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/" + + # 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 + DoNotType needs Accessibility and Microphone permission, and asks for both at first launch. + + macOS revokes Accessibility whenever an app's signature changes, so after an update you may + need to re-grant it in System Settings › Privacy & Security › Accessibility. + + The `dnt` command line ships inside the bundle. To put it on your PATH: + sudo ln -sf "/Applications/DoNotType.app/Contents/MacOS/dnt" /usr/local/bin/dnt + CAVEATS + + app "DoNotType.app" + + # The app stores history, logs and any edited prompt here; `--zap` removes them, an ordinary + # uninstall does not. Deleting somebody's transcripts should take asking for it. + zap trash: [ + "~/Library/Application Support/DoNotType", + "~/Library/Preferences/app.donottype.plist", + ] +end 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/packaging/homebrew/donottype.rb b/packaging/homebrew/donottype.rb deleted file mode 100644 index ff5919b..0000000 --- a/packaging/homebrew/donottype.rb +++ /dev/null @@ -1,42 +0,0 @@ -# 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: -# -# brew install --cask bojieli/tap/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. -cask "donottype" do - version "0.6.2" - sha256 "56a017fe48be8e459156050c3dce8f5fbed38524e9be90aa9c025aefe2862493" - - url "https://github.com/bojieli/DoNotType/releases/download/v#{version}/DoNotType-macOS.zip", - verified: "github.com/bojieli/DoNotType/" - name "DoNotType" - desc "Voice input that transcribes what you said instead of rewriting it" - homepage "https://github.com/bojieli/DoNotType/" - - # 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 - DoNotType needs Accessibility and Microphone permission, and asks for both at first launch. - - macOS revokes Accessibility whenever an app's signature changes, so after an update you may - need to re-grant it in System Settings › Privacy & Security › Accessibility. - - The `dnt` command line ships inside the bundle. To put it on your PATH: - sudo ln -sf "/Applications/DoNotType.app/Contents/MacOS/dnt" /usr/local/bin/dnt - CAVEATS - - app "DoNotType.app" - - # The app stores history, logs and any edited prompt here; `--zap` removes them, an ordinary - # uninstall does not. Deleting somebody's transcripts should take asking for it. - zap trash: [ - "~/Library/Application Support/DoNotType", - "~/Library/Preferences/app.donottype.plist", - ] -end 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."