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
107 changes: 107 additions & 0 deletions .github/workflows/packaging.yml
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions Casks/donottype.rb
Original file line number Diff line number Diff line change
@@ -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-<x>`, 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 <version>`, 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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 39 additions & 16 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<x>`, 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

Expand Down
42 changes: 0 additions & 42 deletions packaging/homebrew/donottype.rb

This file was deleted.

11 changes: 8 additions & 3 deletions scripts/update-packaging.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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."
Loading