From 6a008da58651c0fe833fde429e6049ec394b14e7 Mon Sep 17 00:00:00 2001 From: dennis Date: Thu, 17 Sep 2026 20:05:52 +0200 Subject: [PATCH 1/3] add build, package and release workflows --- .github/actions/ci-scope/action.yml | 47 ++++++ .github/actions/determine-version/action.yml | 51 +++++++ .github/workflows/build.yml | 48 ++++++ .github/workflows/package.yml | 91 +++++++++++ .github/workflows/release.yml | 149 +++++++++++++++++++ Formula/klangladder.rb | 1 + README.md | 12 +- README_DEV.md | 27 +++- bundle.sh | 5 + 9 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 .github/actions/ci-scope/action.yml create mode 100644 .github/actions/determine-version/action.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/package.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/actions/ci-scope/action.yml b/.github/actions/ci-scope/action.yml new file mode 100644 index 0000000..5da2171 --- /dev/null +++ b/.github/actions/ci-scope/action.yml @@ -0,0 +1,47 @@ +name: CI Scope +description: > + Decides which workflow runs standalone for a pull request, so a workflow that another one + calls doesn't run twice. Release calls Package, Package calls Build. The outermost + workflow that has to run wins. + +outputs: + build: + description: "true if Build runs standalone" + value: ${{ steps.scope.outputs.build }} + package: + description: "true if Package runs standalone" + value: ${{ steps.scope.outputs.package }} + release: + description: "true if Release runs (as a dry run)" + value: ${{ steps.scope.outputs.release }} + +runs: + using: composite + steps: + - id: scope + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + BASE: ${{ github.base_ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[].filename') + changed() { grep -qxE "$1" <<<"$files"; } + + # Each workflow also runs when a workflow or action it depends on changes. + shared='\.github/actions/ci-scope/action\.yml' + build_deps="\.github/workflows/build\.yml|$shared" + package_deps="\.github/workflows/package\.yml|$build_deps" + release_deps="\.github/workflows/release\.yml|\.github/actions/determine-version/action\.yml|$package_deps" + + build=false package=false release=false + if changed "$release_deps"; then + release=true + elif [[ "$BASE" == "$DEFAULT_BRANCH" ]] || changed "$package_deps"; then + package=true + else + build=true + fi + echo "build=$build package=$package release=$release" + { echo "build=$build"; echo "package=$package"; echo "release=$release"; } >> "$GITHUB_OUTPUT" diff --git a/.github/actions/determine-version/action.yml b/.github/actions/determine-version/action.yml new file mode 100644 index 0000000..d8d39dc --- /dev/null +++ b/.github/actions/determine-version/action.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1fc411a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,48 @@ +name: Build + +on: + pull_request: + workflow_call: + +permissions: + contents: read + pull-requests: read + +jobs: + scope: + # Only when triggered directly. When called, github.workflow is the caller's name. + if: github.workflow == 'Build' + runs-on: ubuntu-latest + outputs: + run: ${{ steps.scope.outputs.build }} + steps: + - uses: actions/checkout@v5 + with: + sparse-checkout: .github + - id: scope + uses: ./.github/actions/ci-scope + + build: + needs: scope + if: ${{ !cancelled() && (github.workflow != 'Build' || needs.scope.outputs.run == 'true') }} + 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 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..cce37eb --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,91 @@ +name: Package + +on: + pull_request: + workflow_call: + inputs: + version: + description: Version written into the app bundle + type: string + required: false + +permissions: + contents: read + pull-requests: read + +jobs: + scope: + # Only when triggered directly. When called, github.workflow is the caller's name. + if: github.workflow == 'Package' + runs-on: ubuntu-latest + outputs: + run: ${{ steps.scope.outputs.package }} + steps: + - uses: actions/checkout@v5 + with: + sparse-checkout: .github + - id: scope + uses: ./.github/actions/ci-scope + + build: + needs: scope + if: ${{ !cancelled() && (github.workflow != 'Package' || needs.scope.outputs.run == 'true') }} + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f95c9f3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,149 @@ +name: Release + +on: + push: + branches: [main] + # Dry run (no publishing) when a pull request changes this workflow or one it depends on. + pull_request: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: release-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + scope: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + outputs: + run: ${{ steps.scope.outputs.release }} + steps: + - uses: actions/checkout@v5 + with: + sparse-checkout: .github + - id: scope + uses: ./.github/actions/ci-scope + + version: + needs: scope + if: ${{ !cancelled() && (github.event_name == 'push' || needs.scope.outputs.run == 'true') }} + 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 }} + steps: + - uses: actions/download-artifact@v5 + with: + name: KlangLadder-app + + - name: Create release + run: | + mv KlangLadder.zip "KlangLadder-$TAG.zip" + if [[ "$GITHUB_EVENT_NAME" != push ]]; 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' + 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 }} + 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 [[ "$GITHUB_EVENT_NAME" == push ]]; then + pin="exact: \"${TAG#v}\"" + else + pin="revision: \"${{ github.event.pull_request.head.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' + 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 diff --git a/Formula/klangladder.rb b/Formula/klangladder.rb index 850549f..305bf19 100644 --- a/Formula/klangladder.rb +++ b/Formula/klangladder.rb @@ -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 diff --git a/README.md b/README.md index c644e1e..e8d3b9c 100644 --- a/README.md +++ b/README.md @@ -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.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 diff --git a/README_DEV.md b/README_DEV.md index 64f39f9..726871d 100644 --- a/README_DEV.md +++ b/README_DEV.md @@ -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` | every pull request | `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` (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` with the zip, point the formula at the new tag and commit it to `main`, publish the Raycast extension | + +Each workflow also runs on a pull request that changes its own workflow file or one it depends on. Release then runs as a **dry run**: it packages and prints what it would publish, but creates no release, commit or Store submission. + +**No duplicate runs.** Every workflow triggers on all pull requests. Its first job, `scope`, calls `.github/actions/ci-scope`, which reads the changed files and picks the outermost workflow that has to run: Release if release dependencies changed, else Package for pull requests to `main` or when package dependencies changed, else Build. The other two skip their jobs. A called workflow skips `scope`, because `github.workflow` is then the caller's name, and always runs. + +**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"` 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 pull request head 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) diff --git a/bundle.sh b/bundle.sh index b1cd975..b85af8d 100755 --- a/bundle.sh +++ b/bundle.sh @@ -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" From fe5768c2e27275c41d6a3ff8a880c8b9027fb35e Mon Sep 17 00:00:00 2001 From: dennis Date: Thu, 17 Sep 2026 20:28:32 +0200 Subject: [PATCH 2/3] keep the scope job from skipping inside called workflows --- .github/actions/ci-scope/action.yml | 6 ++++++ .github/workflows/build.yml | 5 ++--- .github/workflows/package.yml | 5 ++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/actions/ci-scope/action.yml b/.github/actions/ci-scope/action.yml index 5da2171..da4b6e5 100644 --- a/.github/actions/ci-scope/action.yml +++ b/.github/actions/ci-scope/action.yml @@ -26,6 +26,12 @@ runs: BASE: ${{ github.base_ref }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | + if [[ "$GITHUB_EVENT_NAME" != pull_request ]]; then + # Called by another workflow, or a push. The caller decides what runs. + { echo "build=false"; echo "package=false"; echo "release=false"; } >> "$GITHUB_OUTPUT" + exit 0 + fi + files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[].filename') changed() { grep -qxE "$1" <<<"$files"; } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fc411a..d080c4f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,8 +10,6 @@ permissions: jobs: scope: - # Only when triggered directly. When called, github.workflow is the caller's name. - if: github.workflow == 'Build' runs-on: ubuntu-latest outputs: run: ${{ steps.scope.outputs.build }} @@ -24,7 +22,8 @@ jobs: build: needs: scope - if: ${{ !cancelled() && (github.workflow != 'Build' || needs.scope.outputs.run == 'true') }} + # When called, github.workflow is the caller's name and the caller decides. + if: github.workflow != 'Build' || needs.scope.outputs.run == 'true' runs-on: macos-latest steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index cce37eb..4f29107 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -15,8 +15,6 @@ permissions: jobs: scope: - # Only when triggered directly. When called, github.workflow is the caller's name. - if: github.workflow == 'Package' runs-on: ubuntu-latest outputs: run: ${{ steps.scope.outputs.package }} @@ -29,7 +27,8 @@ jobs: build: needs: scope - if: ${{ !cancelled() && (github.workflow != 'Package' || needs.scope.outputs.run == 'true') }} + # When called, github.workflow is the caller's name and the caller decides. + if: github.workflow != 'Package' || needs.scope.outputs.run == 'true' uses: ./.github/workflows/build.yml app: From 83e866ed85c2bed95c3dc35e50de5bbe8a8902b8 Mon Sep 17 00:00:00 2001 From: dennis Date: Thu, 17 Sep 2026 20:52:26 +0200 Subject: [PATCH 3/3] run release only on main, package only on PRs to main --- .github/actions/ci-scope/action.yml | 53 ----------------------------- .github/workflows/build.yml | 17 ++------- .github/workflows/package.yml | 16 +-------- .github/workflows/release.yml | 40 +++++++++------------- README_DEV.md | 10 +++--- 5 files changed, 24 insertions(+), 112 deletions(-) delete mode 100644 .github/actions/ci-scope/action.yml diff --git a/.github/actions/ci-scope/action.yml b/.github/actions/ci-scope/action.yml deleted file mode 100644 index da4b6e5..0000000 --- a/.github/actions/ci-scope/action.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: CI Scope -description: > - Decides which workflow runs standalone for a pull request, so a workflow that another one - calls doesn't run twice. Release calls Package, Package calls Build. The outermost - workflow that has to run wins. - -outputs: - build: - description: "true if Build runs standalone" - value: ${{ steps.scope.outputs.build }} - package: - description: "true if Package runs standalone" - value: ${{ steps.scope.outputs.package }} - release: - description: "true if Release runs (as a dry run)" - value: ${{ steps.scope.outputs.release }} - -runs: - using: composite - steps: - - id: scope - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR: ${{ github.event.pull_request.number }} - BASE: ${{ github.base_ref }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - if [[ "$GITHUB_EVENT_NAME" != pull_request ]]; then - # Called by another workflow, or a push. The caller decides what runs. - { echo "build=false"; echo "package=false"; echo "release=false"; } >> "$GITHUB_OUTPUT" - exit 0 - fi - - files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[].filename') - changed() { grep -qxE "$1" <<<"$files"; } - - # Each workflow also runs when a workflow or action it depends on changes. - shared='\.github/actions/ci-scope/action\.yml' - build_deps="\.github/workflows/build\.yml|$shared" - package_deps="\.github/workflows/package\.yml|$build_deps" - release_deps="\.github/workflows/release\.yml|\.github/actions/determine-version/action\.yml|$package_deps" - - build=false package=false release=false - if changed "$release_deps"; then - release=true - elif [[ "$BASE" == "$DEFAULT_BRANCH" ]] || changed "$package_deps"; then - package=true - else - build=true - fi - echo "build=$build package=$package release=$release" - { echo "build=$build"; echo "package=$package"; echo "release=$release"; } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d080c4f..63c9a3f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,29 +1,16 @@ name: Build on: + # Pull requests to main run Package, which calls this workflow. pull_request: + branches-ignore: [main] workflow_call: permissions: contents: read - pull-requests: read jobs: - scope: - runs-on: ubuntu-latest - outputs: - run: ${{ steps.scope.outputs.build }} - steps: - - uses: actions/checkout@v5 - with: - sparse-checkout: .github - - id: scope - uses: ./.github/actions/ci-scope - build: - needs: scope - # When called, github.workflow is the caller's name and the caller decides. - if: github.workflow != 'Build' || needs.scope.outputs.run == 'true' runs-on: macos-latest steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 4f29107..ce22c9e 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -2,6 +2,7 @@ name: Package on: pull_request: + branches: [main] workflow_call: inputs: version: @@ -11,24 +12,9 @@ on: permissions: contents: read - pull-requests: read jobs: - scope: - runs-on: ubuntu-latest - outputs: - run: ${{ steps.scope.outputs.package }} - steps: - - uses: actions/checkout@v5 - with: - sparse-checkout: .github - - id: scope - uses: ./.github/actions/ci-scope - build: - needs: scope - # When called, github.workflow is the caller's name and the caller decides. - if: github.workflow != 'Package' || needs.scope.outputs.run == 'true' uses: ./.github/workflows/build.yml app: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f95c9f3..3b11739 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,33 +3,23 @@ name: Release on: push: branches: [main] - # Dry run (no publishing) when a pull request changes this workflow or one it depends on. - pull_request: + # 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 - pull-requests: read concurrency: - group: release-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - scope: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - outputs: - run: ${{ steps.scope.outputs.release }} - steps: - - uses: actions/checkout@v5 - with: - sparse-checkout: .github - - id: scope - uses: ./.github/actions/ci-scope - version: - needs: scope - if: ${{ !cancelled() && (github.event_name == 'push' || needs.scope.outputs.run == 'true') }} runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -52,6 +42,7 @@ jobs: 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: @@ -60,7 +51,7 @@ jobs: - name: Create release run: | mv KlangLadder.zip "KlangLadder-$TAG.zip" - if [[ "$GITHUB_EVENT_NAME" != push ]]; then + if [[ "$PUBLISH" != true ]]; then echo "Dry run: would create release $TAG at $GITHUB_SHA with KlangLadder-$TAG.zip" exit 0 fi @@ -93,7 +84,7 @@ jobs: git diff - name: Commit to the tap - if: github.event_name == 'push' + 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" @@ -107,6 +98,7 @@ jobs: 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: @@ -123,10 +115,10 @@ jobs: 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 [[ "$GITHUB_EVENT_NAME" == push ]]; then + if [[ "$PUBLISH" == true ]]; then pin="exact: \"${TAG#v}\"" else - pin="revision: \"${{ github.event.pull_request.head.sha }}\"" + 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 @@ -139,7 +131,7 @@ jobs: npm run build - name: Publish to the Raycast Store - if: github.event_name == 'push' + if: github.event_name == 'push' || inputs.publish working-directory: raycast run: | if [[ -z "$RAY_TOKEN" || -z "$GITHUB_ACCESS_TOKEN" ]]; then diff --git a/README_DEV.md b/README_DEV.md index 726871d..61958aa 100644 --- a/README_DEV.md +++ b/README_DEV.md @@ -299,19 +299,19 @@ Three workflows in `.github/workflows` call each other: Release calls Package, a | Workflow | Runs on | Does | |---|---|---| -| `build.yml` | every pull request | `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` (compiles the bundled app) | +| `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` with the zip, point the formula at the new tag and commit it to `main`, publish the Raycast extension | -Each workflow also runs on a pull request that changes its own workflow file or one it depends on. Release then runs as a **dry run**: it packages and prints what it would publish, but creates no release, commit or Store submission. +**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. -**No duplicate runs.** Every workflow triggers on all pull requests. Its first job, `scope`, calls `.github/actions/ci-scope`, which reads the changed files and picks the outermost workflow that has to run: Release if release dependencies changed, else Package for pull requests to `main` or when package dependencies changed, else Build. The other two skip their jobs. A called workflow skips `scope`, because `github.workflow` is then the caller's name, and always runs. +**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"` 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 pull request head commit for a dry run), because the Store builds the extension outside this repository. Publishing runs `ray publish` and needs two repository secrets: +**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`)