Skip to content
Open
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
60 changes: 60 additions & 0 deletions .github/release-matrix.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"native": [
{
"target": "windows-x64",
"os": "windows-latest",
"rust-target": "x86_64-pc-windows-msvc",
"bundles": "nsis",
"artifact-kind": "nsis",
"cask_arch": "",
"artifact-path": "target/release/bundle/nsis/*.exe\ncrates/hypercolor-app/target/release/bundle/nsis/*.exe\n",
"signing": false
},
{
"target": "macos-arm64",
"os": "macos-26",
"rust-target": "aarch64-apple-darwin",
"bundles": "app",
"artifact-kind": "dmg",
"cask_arch": "arm64",
"artifact-path": "target/aarch64-apple-darwin/release/bundle/dmg/*.dmg*\n",
"signing": true
},
{
"target": "macos-x64",
"os": "macos-26-intel",
"rust-target": "x86_64-apple-darwin",
"bundles": "app",
"artifact-kind": "dmg",
"cask_arch": "x86_64",
"artifact-path": "target/x86_64-apple-darwin/release/bundle/dmg/*.dmg*\n",
"signing": true
}
],
"release": [
{
"target": "linux-amd64",
"os": "ubuntu-latest",
"rust-target": "x86_64-unknown-linux-gnu",
"signing": false
},
{
"target": "linux-arm64",
"os": "ubuntu-24.04-arm",
"rust-target": "aarch64-unknown-linux-gnu",
"signing": false
},
{
"target": "macos-arm64",
"os": "macos-26",
"rust-target": "aarch64-apple-darwin",
"signing": true
},
{
"target": "macos-amd64",
"os": "macos-26-intel",
"rust-target": "x86_64-apple-darwin",
"signing": true
}
]
}
183 changes: 118 additions & 65 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1516,9 +1516,14 @@ jobs:
startsWith(github.ref, 'refs/tags/') ||
(github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'full')
runs-on: ubuntu-latest
outputs:
macos: ${{ steps.probe.outputs.macos }}
native-matrix: ${{ steps.probe.outputs.native_matrix }}
release-matrix: ${{ steps.probe.outputs.release_matrix }}
steps:
- uses: actions/checkout@v7
- name: Verify signing credentials before building release artifacts
- name: Probe signing credentials and select release lanes
id: probe
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
Expand All @@ -1527,7 +1532,30 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: bash scripts/with-macos-signing.sh true
run: |
set -euo pipefail
# The macOS lanes sign and notarize, so they only run when every
# Apple secret is configured. Without them the release ships Linux
# and Windows, and the Homebrew tap keeps its current macOS build.
missing=()
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
APPLE_TEAM_ID APPLE_API_KEY_ID APPLE_API_ISSUER APPLE_API_KEY_CONTENT; do
[[ -n "${!name:-}" ]] || missing+=("${name}")
done
if (( ${#missing[@]} )); then
macos=false
echo "::warning::macOS signing credentials are not configured (missing ${missing[*]}); this release ships Linux and Windows only."
else
bash scripts/with-macos-signing.sh true
macos=true
fi
echo "macos=${macos}" >> "$GITHUB_OUTPUT"
for lane in native release; do
matrix="$(jq -c --arg lane "${lane}" --argjson macos "${macos}" \
'[.[$lane][] | select($macos or (.signing | not)) | del(.signing)]' \
.github/release-matrix.json)"
echo "${lane}_matrix=${matrix}" >> "$GITHUB_OUTPUT"
done

build-native-app:
name: Native App (${{ matrix.target }})
Expand All @@ -1539,32 +1567,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- target: windows-x64
os: windows-latest
rust-target: x86_64-pc-windows-msvc
bundles: nsis
artifact-kind: nsis
cask_arch: ""
artifact-path: |
target/release/bundle/nsis/*.exe
crates/hypercolor-app/target/release/bundle/nsis/*.exe
- target: macos-arm64
os: macos-26
rust-target: aarch64-apple-darwin
bundles: app
artifact-kind: dmg
cask_arch: arm64
artifact-path: |
target/aarch64-apple-darwin/release/bundle/dmg/*.dmg*
- target: macos-x64
os: macos-26-intel
rust-target: x86_64-apple-darwin
bundles: app
artifact-kind: dmg
cask_arch: x86_64
artifact-path: |
target/x86_64-apple-darwin/release/bundle/dmg/*.dmg*
include: ${{ fromJSON(needs.release-credentials.outputs.native-matrix) }}
runs-on: ${{ matrix.os }}
env:
# Absolute on purpose. Cargo resolves a relative CARGO_TARGET_DIR
Expand Down Expand Up @@ -1897,19 +1900,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- target: linux-amd64
os: ubuntu-latest
rust-target: x86_64-unknown-linux-gnu
- target: linux-arm64
os: ubuntu-24.04-arm
rust-target: aarch64-unknown-linux-gnu
- target: macos-arm64
os: macos-26
rust-target: aarch64-apple-darwin
- target: macos-amd64
os: macos-26-intel
rust-target: x86_64-apple-darwin
include: ${{ fromJSON(needs.release-credentials.outputs.release-matrix) }}
# A full workflow_dispatch on the default branch warms these release
# shapes. Tags restore that trusted cache; they never publish cache state.
timeout-minutes: 120
Expand Down Expand Up @@ -2176,7 +2167,30 @@ jobs:
args+=(--prerelease)
fi

gh release create "${args[@]}" "${files[@]}"
# A tag lane re-run after the macOS signing credentials land adds
# the signed artifacts to the release the first run created. Assets
# already on the release keep their published digests: a rebuild is
# not byte-identical, and the tap, AUR, and Nix pins point at the
# first run's checksums.
if existing="$(gh release view "${GITHUB_REF_NAME}" --repo "${{ github.repository }}" \
--json assets --jq '.assets[].name' 2>/dev/null)"; then
new_files=()
for file in "${files[@]}"; do
if grep -qxF "$(basename "${file}")" <<<"${existing}"; then
echo " keeping published $(basename "${file}")"
else
new_files+=("${file}")
fi
done
if (( ${#new_files[@]} == 0 )); then
echo "Release ${GITHUB_REF_NAME} already carries every artifact from this run"
else
echo "Release ${GITHUB_REF_NAME} exists; adding ${#new_files[@]} new artifacts"
gh release upload "${GITHUB_REF_NAME}" --repo "${{ github.repository }}" "${new_files[@]}"
fi
else
gh release create "${args[@]}" "${files[@]}"
fi

# ── Publish npm Packages ───────────────────────────────────────
# Uses npm trusted publishing (OIDC): no token, and provenance is
Expand Down Expand Up @@ -2255,6 +2269,7 @@ jobs:
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true
packages-dir: dist/

# ── Update AUR Package ────────────────────────────────────────
Expand Down Expand Up @@ -2386,23 +2401,53 @@ jobs:
run: |
set -euo pipefail
mkdir -p release-artifacts
for platform in linux-amd64 linux-arm64 macos-amd64 macos-arm64; do
tarball="hypercolor-${VERSION}-${platform}.tar.gz"
assets="$(gh release view "v${VERSION}" --repo "${{ github.repository }}" \
--json assets --jq '.assets[].name')"
published() { grep -qxF "$1" <<<"${assets}"; }
digest() {
gh release download "v${VERSION}" \
--repo "${{ github.repository }}" \
--pattern "${tarball}" \
--pattern "$1" \
--dir release-artifacts
sha=$(sha256sum "release-artifacts/${tarball}" | cut -d' ' -f1)
sha256sum "release-artifacts/$1" | cut -d' ' -f1
}
for platform in linux-amd64 linux-arm64; do
sha="$(digest "hypercolor-${VERSION}-${platform}.tar.gz")"
echo "sha256_${platform//-/_}=${sha}" >> "$GITHUB_OUTPUT"
echo " ${platform}: ${sha}"
done
for arch in arm64 x86_64; do
dmg="Hypercolor-${VERSION}-${arch}.dmg"
gh release download "v${VERSION}" \
--repo "${{ github.repository }}" --pattern "$dmg" --dir release-artifacts
sha=$(sha256sum "release-artifacts/${dmg}" | cut -d' ' -f1)
echo "sha256_dmg_${arch}=${sha}" >> "$GITHUB_OUTPUT"
# The signed lane publishes macOS as one set. A tag that shipped
# none keeps the tap's current macOS build; a tag that shipped
# part of the set is a broken release, not a Linux-only one.
macos_assets=(
"hypercolor-${VERSION}-macos-amd64.tar.gz"
"hypercolor-${VERSION}-macos-arm64.tar.gz"
"Hypercolor-${VERSION}-arm64.dmg"
"Hypercolor-${VERSION}-x86_64.dmg"
)
present=0
for asset in "${macos_assets[@]}"; do
if published "${asset}"; then present=$((present + 1)); fi
done
if (( present == 0 )); then
echo "macos=false" >> "$GITHUB_OUTPUT"
echo " macOS: no artifacts published; the tap keeps its current macOS build"
elif (( present < ${#macos_assets[@]} )); then
echo "::error::v${VERSION} published ${present} of ${#macos_assets[@]} macOS artifacts; refusing to advance the tap"
exit 1
else
echo "macos=true" >> "$GITHUB_OUTPUT"
for platform in macos-amd64 macos-arm64; do
sha="$(digest "hypercolor-${VERSION}-${platform}.tar.gz")"
echo "sha256_${platform//-/_}=${sha}" >> "$GITHUB_OUTPUT"
echo " ${platform}: ${sha}"
done
for arch in arm64 x86_64; do
sha="$(digest "Hypercolor-${VERSION}-${arch}.dmg")"
echo "sha256_dmg_${arch}=${sha}" >> "$GITHUB_OUTPUT"
echo " dmg ${arch}: ${sha}"
done
fi

- name: Verify Homebrew tap token
env:
Expand Down Expand Up @@ -2433,6 +2478,7 @@ jobs:
- name: Render formula and cask
env:
VERSION: ${{ steps.version.outputs.version }}
MACOS_PUBLISHED: ${{ steps.checksums.outputs.macos }}
SHA256_LINUX_AMD64: ${{ steps.checksums.outputs.sha256_linux_amd64 }}
SHA256_LINUX_ARM64: ${{ steps.checksums.outputs.sha256_linux_arm64 }}
SHA256_MACOS_AMD64: ${{ steps.checksums.outputs.sha256_macos_amd64 }}
Expand All @@ -2442,18 +2488,25 @@ jobs:
run: |
set -euo pipefail
mkdir -p homebrew-tap/Formula homebrew-tap/Casks
node scripts/homebrew-formula.mjs \
--version "$VERSION" \
--linux-amd64 "$SHA256_LINUX_AMD64" \
--linux-arm64 "$SHA256_LINUX_ARM64" \
--macos-amd64 "$SHA256_MACOS_AMD64" \
--macos-arm64 "$SHA256_MACOS_ARM64" \
--dmg-arm64 "$SHA256_DMG_ARM64" \
--dmg-x86_64 "$SHA256_DMG_X86_64" \
--template packaging/homebrew/hypercolor.rb \
--cask-template packaging/homebrew/hypercolor-app.rb \
--output homebrew-tap/Formula/hypercolor.rb \
--cask-output homebrew-tap/Casks/hypercolor-app.rb
args=(
--version "$VERSION"
--linux-amd64 "$SHA256_LINUX_AMD64"
--linux-arm64 "$SHA256_LINUX_ARM64"
--template packaging/homebrew/hypercolor.rb
--current homebrew-tap/Formula/hypercolor.rb
--output homebrew-tap/Formula/hypercolor.rb
)
if [[ "${MACOS_PUBLISHED}" == "true" ]]; then
args+=(
--macos-amd64 "$SHA256_MACOS_AMD64"
--macos-arm64 "$SHA256_MACOS_ARM64"
--dmg-arm64 "$SHA256_DMG_ARM64"
--dmg-x86_64 "$SHA256_DMG_X86_64"
--cask-template packaging/homebrew/hypercolor-app.rb
--cask-output homebrew-tap/Casks/hypercolor-app.rb
)
fi
node scripts/homebrew-formula.mjs "${args[@]}"
echo "Rendered formula:"
cat homebrew-tap/Formula/hypercolor.rb

Expand Down Expand Up @@ -2481,7 +2534,7 @@ jobs:
fi
git commit \
-m "hypercolor: update to ${VERSION}" \
-m "Update formula and cask from the published release checksums."
-m "Update the Homebrew packages from the published v${VERSION} release. macOS stanzas advance only when the release shipped its signed macOS artifacts; otherwise they are carried forward."
git push

# ── Update Nix Release Pin ───────────────────────────────────────
Expand Down
21 changes: 13 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ jobs:
ref: main
fetch-depth: 0

- name: Require macOS signing credentials
if: inputs.dry_run == false
- name: Report macOS signing credentials
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
Expand All @@ -61,16 +60,22 @@ jobs:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
set -euo pipefail
missing=0
# Linux and Windows never need these. The tag lane skips the macOS
# builds when any are missing and the Homebrew tap keeps its
# current macOS build, so a missing secret narrows the release
# instead of blocking it.
missing=()
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD \
APPLE_SIGNING_IDENTITY APPLE_TEAM_ID APPLE_API_KEY_ID \
APPLE_API_ISSUER APPLE_API_KEY_CONTENT; do
if [[ -z "${!name}" ]]; then
echo "Missing required repository secret: ${name}" >&2
missing=1
fi
[[ -n "${!name:-}" ]] || missing+=("${name}")
done
exit "${missing}"
if (( ${#missing[@]} )); then
echo "macOS signing credentials are not configured; this release ships Linux and Windows only. Missing: ${missing[*]}" >&2
echo "::warning::macOS signing credentials are not configured (missing ${missing[*]}); this release ships Linux and Windows only."
else
echo "macOS signing credentials are configured; the tag lane will sign and notarize macOS artifacts."
fi

- name: Require passing CI for the release source
env:
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,18 +353,20 @@ Duplication and is enabled by default.

### Install on macOS

Download
`Hypercolor-<version>-arm64.dmg` for Apple Silicon or the `-x86_64.dmg` build for
Intel from the
[GitHub releases page](https://github.com/hyperb1iss/hypercolor/releases). Drag
the app into `/Applications` and launch. Minimum macOS 15.2 (Sequoia).

Or via Homebrew Cask:
macOS builds need Developer ID signing and notarization, so they ship only
when the signed lane runs and can lag the Linux and Windows releases. The
Homebrew tap always points at the newest macOS build for each package:

```bash
brew install --cask hyperb1iss/tap/hypercolor-app
brew install hyperb1iss/tap/hypercolor # daemon, CLI, and TUI
brew install --cask hyperb1iss/tap/hypercolor-app # desktop app
```

Minimum macOS 15.2 (Sequoia). Check `brew info hyperb1iss/tap/hypercolor` for
the macOS version the tap currently serves; the
[GitHub releases page](https://github.com/hyperb1iss/hypercolor/releases)
carries the DMGs for releases that shipped a signed macOS build.

Hue, WLED, Nanoleaf, Govee, and USB-HID lighting all work out of the box. Hypercolor asks
for Microphone, Screen Recording, or Input Monitoring access only when you explicitly
enable the matching audio, screen, or keyboard feature. Pointer-only effects do not need
Expand Down
Loading