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
51 changes: 51 additions & 0 deletions .github/actions/determine-version/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Determine Version
description: Determines the version for a release - uses tag if available, otherwise auto-increments patch version from latest release

outputs:
version:
description: The determined version number (without v prefix)
value: ${{ steps.set-version.outputs.version }}

runs:
using: composite
steps:
- name: Determine Version
id: set-version
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
# Check if triggered by a tag push
if [[ "${{ github.ref_type }}" == "tag" ]]; then
TAG="${{ github.ref_name }}"
echo "Triggered by tag: $TAG"
# Strip 'v' prefix for npm version compatibility
echo "version=${TAG#v}" >> $GITHUB_OUTPUT
exit 0
fi

# Fetch the latest release and increment patch version
echo "Fetching latest release to determine next version..."
LATEST_RELEASE=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || echo "")

if [ -z "$LATEST_RELEASE" ]; then
echo "No previous release found. Starting at 0.0.1"
echo "version=0.0.1" >> $GITHUB_OUTPUT
exit 0
fi

echo "Latest release: $LATEST_RELEASE"

# Remove 'v' prefix and parse
VERSION_NO_V=${LATEST_RELEASE#v}
IFS='.' read -r -a parts <<< "$VERSION_NO_V"
MAJOR=${parts[0]:-0}
MINOR=${parts[1]:-0}
PATCH=${parts[2]:-0}

# Increment patch version
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"

echo "New version: $NEW_VERSION"
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
34 changes: 34 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Build

on:
# Pull requests to main run Package, which calls this workflow.
pull_request:
branches-ignore: [main]
workflow_call:

permissions:
contents: read

jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v5

- name: Build app
run: swift build

- name: Test
run: swift test

- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: raycast/package-lock.json

- name: Lint Raycast extension
working-directory: raycast
run: |
npm ci
npm run lint
76 changes: 76 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Package

on:
pull_request:
branches: [main]
workflow_call:
inputs:
version:
description: Version written into the app bundle
type: string
required: false

permissions:
contents: read

jobs:
build:
uses: ./.github/workflows/build.yml

app:
needs: build
runs-on: macos-latest
steps:
- uses: actions/checkout@v5

- name: Bundle app
env:
VERSION: ${{ inputs.version }}
run: ./bundle.sh

- name: Verify bundle
run: |
plutil -lint build/KlangLadder.app/Contents/Info.plist
codesign --verify --strict build/KlangLadder.app
ditto -c -k --keepParent build/KlangLadder.app KlangLadder.zip

- uses: actions/upload-artifact@v4
with:
name: KlangLadder-app
path: KlangLadder.zip

homebrew:
needs: build
runs-on: macos-latest
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
steps:
- uses: actions/checkout@v5

- name: Install formula from this commit
run: |
git branch -f ci-formula HEAD
brew tap-new --no-git local/ci
sed -E "s#^ head .*# head \"file://$GITHUB_WORKSPACE\", branch: \"ci-formula\", using: :git#" \
Formula/klangladder.rb > "$(brew --repository)/Library/Taps/local/homebrew-ci/Formula/klangladder.rb"
brew install --HEAD local/ci/klangladder
brew test local/ci/klangladder
brew audit --strict --formula local/ci/klangladder

raycast:
needs: build
runs-on: macos-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: raycast/package-lock.json

- name: Build Raycast extension with the bundled app
working-directory: raycast
run: |
npm ci
npm run build
141 changes: 141 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
name: Release

on:
push:
branches: [main]
# Manual run to check the release path. Without "publish" it stops before publishing.
workflow_dispatch:
inputs:
publish:
description: Create the release, update the tap and publish to the Raycast Store
type: boolean
default: false

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
- id: version
uses: ./.github/actions/determine-version

package:
needs: version
uses: ./.github/workflows/package.yml
with:
version: ${{ needs.version.outputs.version }}

github-release:
needs: [version, package]
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
TAG: v${{ needs.version.outputs.version }}
PUBLISH: ${{ github.event_name == 'push' || inputs.publish }}
steps:
- uses: actions/download-artifact@v5
with:
name: KlangLadder-app

- name: Create release
run: |
mv KlangLadder.zip "KlangLadder-$TAG.zip"
if [[ "$PUBLISH" != true ]]; then
echo "Dry run: would create release $TAG at $GITHUB_SHA with KlangLadder-$TAG.zip"
exit 0
fi
gh release create "$TAG" "KlangLadder-$TAG.zip" \
--repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "$TAG" --generate-notes \
--notes "The app is not notarized, so Gatekeeper blocks it after download. Open it once, then allow it in System Settings → Privacy & Security. Installing with Homebrew or Raycast avoids this."

homebrew:
needs: [version, github-release]
runs-on: ubuntu-latest
permissions:
contents: write
env:
TAG: v${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.repository.default_branch }}

- name: Point the formula at the release tag
run: |
ruby - <<'RUBY'
path = "Formula/klangladder.rb"
stable = %( url "https://github.com/#{ENV["GITHUB_REPOSITORY"]}.git",\n) +
%( tag: "#{ENV["TAG"]}",\n) +
%( revision: "#{ENV["GITHUB_SHA"]}"\n)
formula = File.read(path).sub(/^ url .*?revision: "\h+"\n/m, "")
File.write(path, formula.sub(/^ homepage .*\n/) { |line| line + stable })
RUBY
git diff

- name: Commit to the tap
if: github.event_name == 'push' || inputs.publish
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git diff --quiet && exit 0
git commit -am "klangladder $TAG"
git pull --rebase
git push

raycast:
needs: [version, github-release]
runs-on: macos-latest
env:
TAG: v${{ needs.version.outputs.version }}
PUBLISH: ${{ github.event_name == 'push' || inputs.publish }}
RAY_TOKEN: ${{ secrets.RAYCAST_TOKEN }}
GITHUB_ACCESS_TOKEN: ${{ secrets.RAYCAST_GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v5

- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: raycast/package-lock.json

- name: Depend on the released app sources
working-directory: raycast/swift
run: |
# The Store builds the extension outside this repo, so the path dependency can't work there.
# Before the tag exists (dry run), pin the commit instead.
if [[ "$PUBLISH" == true ]]; then
pin="exact: \"${TAG#v}\""
else
pin="revision: \"$GITHUB_SHA\""
fi
sed -i '' "s#\.package(path: \"\.\./\.\.\")#.package(url: \"https://github.com/$GITHUB_REPOSITORY\", $pin)#" Package.swift
grep -F "$GITHUB_REPOSITORY" Package.swift
rm -f Package.resolved

- name: Build
working-directory: raycast
run: |
npm ci
npm run build

- name: Publish to the Raycast Store
if: github.event_name == 'push' || inputs.publish
working-directory: raycast
run: |
if [[ -z "$RAY_TOKEN" || -z "$GITHUB_ACCESS_TOKEN" ]]; then
echo "::warning::RAYCAST_TOKEN or RAYCAST_GITHUB_TOKEN secret missing, skipping Store publish"
exit 0
fi
npm run publish
1 change: 1 addition & 0 deletions Formula/klangladder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Klangladder < Formula

def install
# SwiftPM's own sandbox can't nest inside Homebrew's.
ENV["VERSION"] = version.to_s unless build.head?
system "./bundle.sh", "--disable-sandbox"
prefix.install "build/KlangLadder.app"
end
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,23 @@ KlangLadder reacts to system events. It does not poll, and it needs no special p

### Homebrew

The formula builds KlangLadder on your machine, so it needs the Xcode Command Line Tools. It builds the latest `main` until the first release is tagged.
The formula builds KlangLadder on your machine, so it needs the Xcode Command Line Tools. It builds the latest release. Add `--HEAD` to build the latest `main` instead.

```sh
brew tap janthoXO/klangladder https://github.com/janthoXO/KlangLadder
brew install --HEAD klangladder
brew install klangladder
brew services start klangladder
```

`brew services start` runs KlangLadder now and at every login. Use it instead of the **Launch at login** toggle. To run it once without a service, use `open $(brew --prefix)/opt/klangladder/KlangLadder.app`.

Update with `brew upgrade --fetch-HEAD klangladder`, then `brew services restart klangladder`.
Update with `brew upgrade klangladder`, then `brew services restart klangladder`.

### From source
### GitHub release

Download `KlangLadder-v<version>.zip` from the [latest release](https://github.com/janthoXO/KlangLadder/releases/latest), unzip it and move `KlangLadder.app` to `~/Applications`. The app is not notarized, so Gatekeeper blocks the first launch. Allow it in **System Settings → Privacy & Security**.

GitHub releases are planned.
### From source

```sh
git clone https://github.com/janthoXO/KlangLadder.git
Expand Down
27 changes: 25 additions & 2 deletions README_DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,12 +293,35 @@ brew audit --strict --formula local/klangtest/klangladder
brew test local/klangtest/klangladder
```

## CI and releases

Three workflows in `.github/workflows` call each other: Release calls Package, and Package calls Build.

| Workflow | Runs on | Does |
|---|---|---|
| `build.yml` | pull requests to any branch except `main` | `swift build`, `swift test`, `ray lint` |
| `package.yml` | pull requests to `main` | Build, then in parallel: `bundle.sh` and upload the zipped app as the `KlangLadder-app` artifact; install, test and audit the formula from a local tap; `ray build`, which compiles the bundled app |
| `release.yml` | every push to `main` | Determine the version (`.github/actions/determine-version`), Package with that version, create GitHub release `v<version>` with the zip, point the formula at the new tag and commit it to `main`, publish the Raycast extension |

**No duplicate runs.** The triggers don't overlap: Build skips pull requests to `main`, because Package runs there and calls it. Release only runs on `main`, and calls Package.

**Checking a release without publishing.** Run Release manually (`workflow_dispatch`) and leave `publish` off. It builds everything and prints what it would publish, but creates no release, commit or Store submission.

**Versioning.** `bundle.sh` writes `$VERSION` into the Info.plist when it's set. The formula sets it from the tag for stable builds.

**Homebrew.** The release job rewrites the formula's `url` to `tag: "v<version>"` plus `revision:`, then commits to `main` as `github-actions[bot]`. Pushes made with `GITHUB_TOKEN` don't trigger workflows, so this doesn't start another release. If `main` gets branch protection, allow the bot to push or switch to a pull request.

**Raycast Store.** Before building, the job replaces the `../..` path dependency in `raycast/swift/Package.swift` with the GitHub URL at the new version (at the current commit for a dry run), because the Store builds the extension outside this repository. Publishing runs `ray publish` and needs two repository secrets:

- `RAYCAST_TOKEN`: Raycast access token (`RAY_TOKEN`)
- `RAYCAST_GITHUB_TOKEN`: GitHub token that can fork `raycast/extensions` and open pull requests (`GITHUB_ACCESS_TOKEN`)

Without them the step logs a warning and skips. See #21 for what else the Store needs.

## Roadmap

See the GitHub issues and DESIGN.md sections 13–14. Main open items:

- Homebrew: stable version after the first tag (#1, #3)
- Raycast extension (#2) — the extension bundles and installs the app (9.2, S1); Raycast Store acceptance (S2) and switching the path dependency to a tagged release are still open
- GitHub releases (#3)
- CLI mode for reads (#11)
- URL write commands (#12)
5 changes: 5 additions & 0 deletions bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ cd "$(dirname "$0")"
swift build -c release "$@"
.build/release/KlangLadder --bundle build/KlangLadder.app

if [[ -n "${VERSION:-}" ]]; then
plutil -replace CFBundleShortVersionString -string "$VERSION" build/KlangLadder.app/Contents/Info.plist
codesign --force --sign - build/KlangLadder.app
fi

echo "Built build/KlangLadder.app"
Loading