Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .github/workflows/build_reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ on:
required: false
type: string
default: "Apple Development"
commit_version_changes:
required: false
type: boolean
default: true

jobs:
build:
Expand Down Expand Up @@ -84,6 +88,7 @@ jobs:
sed -i '' "s/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = ${BUILD_NUMBER}/g" "$PBXPROJ"

- name: Commit version changes
if: ${{ inputs.commit_version_changes }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
Expand Down
161 changes: 161 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
name: Nightly Branch Builds

on:
push:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think nightly builds should be on every push, this should be a job that runs once a day

branches:
- main
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can drop the main nightly (at least for now), as main only gets code changes that are released into stable

- dev
paths:
- "boringNotch/**"
- "BoringNotchXPCHelper/**"
- "boringNotch.xcodeproj/**"
- "Configuration/**"
- "mediaremote-adapter/**"

concurrency:
group: nightly-${{ github.ref_name }}
cancel-in-progress: true

permissions:
contents: write

env:
PROJECT_NAME: boringNotch

jobs:
prepare:
name: Prepare nightly metadata
runs-on: ubuntu-latest
outputs:
branch_name: ${{ steps.meta.outputs.branch_name }}
commit_sha: ${{ steps.meta.outputs.commit_sha }}
short_sha: ${{ steps.meta.outputs.short_sha }}
tag: ${{ steps.meta.outputs.tag }}
appcast_file: ${{ steps.meta.outputs.appcast_file }}
build_number: ${{ steps.meta.outputs.build_number }}
asset_name: ${{ steps.meta.outputs.asset_name }}
steps:
- name: Compute metadata
id: meta
run: |
set -euo pipefail
BRANCH_NAME="${GITHUB_REF_NAME}"
COMMIT_SHA="${GITHUB_SHA}"
SHORT_SHA="${COMMIT_SHA::7}"
TAG="nightly-${BRANCH_NAME}-${SHORT_SHA}"
APPCAST_FILE="appcast-${BRANCH_NAME}.xml"
BUILD_NUMBER="$(date -u +%Y%m%d%H%M%S)"
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BUILD_NUMBER is derived from a UTC timestamp with only second precision. On fast successive pushes (or a canceled run restarting quickly), two different commits can end up with the same build number, which can confuse Sparkle’s update ordering. Consider including ${GITHUB_RUN_NUMBER}/${GITHUB_RUN_ATTEMPT} (or using higher precision time) to ensure uniqueness.

Suggested change
BUILD_NUMBER="$(date -u +%Y%m%d%H%M%S)"
BUILD_NUMBER="$(date -u +%Y%m%d%H%M%S)${GITHUB_RUN_NUMBER}${GITHUB_RUN_ATTEMPT}"

Copilot uses AI. Check for mistakes.
ASSET_NAME="boringNotch-${BRANCH_NAME}-${SHORT_SHA}.dmg"
{
echo "branch_name=$BRANCH_NAME"
echo "commit_sha=$COMMIT_SHA"
echo "short_sha=$SHORT_SHA"
echo "tag=$TAG"
echo "appcast_file=$APPCAST_FILE"
echo "build_number=$BUILD_NUMBER"
echo "asset_name=$ASSET_NAME"
} >> "$GITHUB_OUTPUT"

build:
name: Build nightly DMG
needs: [prepare]
uses: ./.github/workflows/build_reusable.yml
with:
head_ref: ${{ needs.prepare.outputs.branch_name }}
version: ""
build_number: ${{ needs.prepare.outputs.build_number }}
xcode_version: "16.4"
code_sign_identity: "Apple Development"
commit_version_changes: false
secrets:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}

publish:
name: Publish nightly channel
needs: [prepare, build]
runs-on: macos-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SPARKLE_PRIVATE_KEY: ${{ secrets.PRIVATE_SPARKLE_KEY }}
BRANCH_NAME: ${{ needs.prepare.outputs.branch_name }}
COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
TAG: ${{ needs.prepare.outputs.tag }}
APPCAST_FILE: ${{ needs.prepare.outputs.appcast_file }}
ASSET_NAME: ${{ needs.prepare.outputs.asset_name }}
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
steps:
- name: Checkout branch
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: ${{ env.BRANCH_NAME }}

- name: Download DMG artifact
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: ${{ env.PROJECT_NAME }}.dmg
path: Release

- name: Rename DMG to immutable name
run: |
set -euo pipefail
mv "Release/${PROJECT_NAME}.dmg" "Release/${ASSET_NAME}"

- name: Create embedded release notes
run: |
cat > Release/boringNotch.html <<HTML
<h2>Nightly ${BRANCH_NAME}</h2>
<p>Commit: <code>${COMMIT_SHA}</code></p>
<p>Message: ${COMMIT_MESSAGE}</p>
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated HTML release notes interpolate ${COMMIT_MESSAGE} directly. Commit messages can contain characters/newlines that break the HTML, and (more importantly) can inject arbitrary HTML into the Sparkle release notes view. Escape/sanitize the commit message before embedding it, or render it as plain text (eg, inside <pre> with escaping).

Suggested change
cat > Release/boringNotch.html <<HTML
<h2>Nightly ${BRANCH_NAME}</h2>
<p>Commit: <code>${COMMIT_SHA}</code></p>
<p>Message: ${COMMIT_MESSAGE}</p>
ESCAPED_COMMIT_MESSAGE=$(printf '%s' "$COMMIT_MESSAGE" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g')
cat > Release/boringNotch.html <<HTML
<h2>Nightly ${BRANCH_NAME}</h2>
<p>Commit: <code>${COMMIT_SHA}</code></p>
<p>Message: ${ESCAPED_COMMIT_MESSAGE}</p>

Copilot uses AI. Check for mistakes.
<p>Source: <a href="https://github.com/TheBoredTeam/boring.notch/commit/${COMMIT_SHA}">View commit</a></p>
HTML

- name: Generate signed nightly appcast
run: |
set -euo pipefail
DOWNLOAD_PREFIX="https://github.com/TheBoredTeam/boring.notch/releases/download/${TAG}/"
printf '%s' "$SPARKLE_PRIVATE_KEY" | ./Configuration/sparkle/generate_appcast \
Comment on lines +124 to +125
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the release workflow, this job doesn’t verify that Configuration/sparkle/generate_appcast exists and is executable before invoking it. Adding an explicit check with a clear error will make failures easier to diagnose (and avoids a more cryptic “file not found/permission denied” error).

Suggested change
DOWNLOAD_PREFIX="https://github.com/TheBoredTeam/boring.notch/releases/download/${TAG}/"
printf '%s' "$SPARKLE_PRIVATE_KEY" | ./Configuration/sparkle/generate_appcast \
GEN_APPCAST="./Configuration/sparkle/generate_appcast"
if [ ! -x "$GEN_APPCAST" ]; then
echo "Error: Expected appcast generator '$GEN_APPCAST' to exist and be executable." >&2
exit 1
fi
DOWNLOAD_PREFIX="https://github.com/TheBoredTeam/boring.notch/releases/download/${TAG}/"
printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GEN_APPCAST" \

Copilot uses AI. Check for mistakes.
--ed-key-file - \
--link "https://github.com/TheBoredTeam/boring.notch/commits/${BRANCH_NAME}" \
--download-url-prefix "${DOWNLOAD_PREFIX}" \
--embed-release-notes \
--channel "${BRANCH_NAME}" \
-o "updater/${APPCAST_FILE}" \
Release/

- name: Create immutable nightly release
run: |
set -euo pipefail
TITLE="Nightly ${BRANCH_NAME}"
NOTES="Automated nightly build for ${BRANCH_NAME}. Commit: ${SHORT_SHA}"
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists; keeping immutable release."
else
gh release create "$TAG" \
--title "$TITLE" \
--notes "$NOTES" \
--prerelease \
--target "$COMMIT_SHA"
fi

- name: Upload immutable nightly DMG
run: |
set -euo pipefail
if gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq "$ASSET_NAME"; then
echo "Asset $ASSET_NAME already exists in $TAG; skipping upload."
else
gh release upload "$TAG" "Release/${ASSET_NAME}"
fi

- name: Commit branch appcast pointer
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "updater/${APPCAST_FILE}"
git commit -m "Update ${APPCAST_FILE} to ${SHORT_SHA}" || true
git push origin "HEAD:${BRANCH_NAME}" || true
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The git commit/git push are followed by || true, which will mark the workflow successful even if the appcast update fails to be committed/pushed (eg, due to branch protection or a race). This can leave the nightly channel silently stale. Consider failing the job on push failure, or at least emitting an explicit error and exiting non-zero when the push doesn’t succeed.

Suggested change
git commit -m "Update ${APPCAST_FILE} to ${SHORT_SHA}" || true
git push origin "HEAD:${BRANCH_NAME}" || true
if git diff --cached --quiet; then
echo "No changes detected in ${APPCAST_FILE}; skipping commit and push."
else
git commit -m "Update ${APPCAST_FILE} to ${SHORT_SHA}"
if ! git push origin "HEAD:${BRANCH_NAME}"; then
echo "Error: Failed to push appcast update to ${BRANCH_NAME}. Nightly channel may be stale." >&2
exit 1
fi
fi

Copilot uses AI. Check for mistakes.
8 changes: 3 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
OUTPUT=$(python3 .github/scripts/extract_version.py -c "$COMMENT")
VERSION=$(awk -F= '/^version=/{print $2; exit}' <<<"$OUTPUT")
IS_BETA=$(awk -F= '/^is_beta=/{print $2; exit}' <<<"$OUTPUT")
BUILD_NUMBER="${GITHUB_RUN_NUMBER}"
BUILD_NUMBER="$(date -u +%Y%m%d%H%M%S)"
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BUILD_NUMBER is generated with second-level precision. If two runs start within the same second (eg, re-run/cancel+rerun), this can produce identical build numbers, which can break Sparkle update ordering/deduping. Consider appending ${GITHUB_RUN_NUMBER}/${GITHUB_RUN_ATTEMPT} (or using nanoseconds) to guarantee uniqueness while keeping monotonic ordering.

Suggested change
BUILD_NUMBER="$(date -u +%Y%m%d%H%M%S)"
BUILD_NUMBER="$(date -u +%Y%m%d%H%M%S)$(printf '%06d%02d' "${GITHUB_RUN_NUMBER:-0}" "${GITHUB_RUN_ATTEMPT:-0}")"

Copilot uses AI. Check for mistakes.
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "is_beta=$IS_BETA" >> "$GITHUB_OUTPUT"
echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -180,14 +180,12 @@ jobs:
head_ref: ${{ needs.preparation.outputs.head_ref }}
version: ${{ needs.preparation.outputs.version }}
build_number: ${{ needs.preparation.outputs.build_number }}
xcode_version: ${{ needs.preparation.outputs.xcode_version }}
code_sign_identity: ${{ needs.preparation.outputs.code_sign_identity }}
xcode_version: ${{ env.XCODE_VERSION }}
code_sign_identity: ${{ env.CODE_SIGN_IDENTITY }}
Comment on lines -175 to +186
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This job calls the reusable build workflow, which contains a git push step. Because this workflow’s top-level permissions set contents: read and this job doesn’t override it, the called workflow will also only have read access, causing the push to fail (and the failure is currently easy to miss). Set permissions: contents: write on this build job (or disable commit_version_changes here) so the release process is deterministic.

Copilot uses AI. Check for mistakes.
secrets:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}

publish:
name: Publish release
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ Please submit all translations to [Crowdin](https://crowdin.com/project/boring-n

4. **Be patient**: Reviews take time. Maintainers will get to your PR as soon as they can.

### Release Channels (Maintainers)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think we need this here, its not really related to contributing guidelines


- **Stable/Beta** are produced by the release workflow.
- **Nightly (Main/Dev)** builds are produced on code pushes to `main` and `dev` branches.
- Nightly builds are not generated from the `pull_request` event itself.

<!-- ## Code Style Guidelines

- Follow the existing code style and conventions used in the project
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ brew install --cask TheBoredTeam/boring-notch/boring-notch --no-quarantine
- Use the controls to manage your music like a rockstar.
- Click the star in your menu bar to customize your notch to your heart's content.

## Update Channels

Boring Notch supports multiple update channels in **Settings → About → Software updates**:

- **Stable**: Official public releases.
- **Beta**: Prerelease builds from the release pipeline.
- **Main (Nightly)**: Automated nightly builds from the `main` branch.
- **Dev (Nightly)**: Automated nightly builds from the `dev` branch.

> [!NOTE]
> Nightly channels are generated from code pushes to their branch (not from pull request events).

## 📋 Roadmap
- [x] Playback live activity 🎧
- [x] Calendar integration 📆
Expand Down Expand Up @@ -188,4 +200,3 @@ For a full list of licenses and attributions, please see the [Third-Party Licens
- **SwiftUI**: For making us look like coding wizards.
- **You**: For being awesome and checking out **boring.notch**!


4 changes: 3 additions & 1 deletion boringNotch/boringNotchApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ struct DynamicNotchApp: App {
@Default(.menubarIcon) var showMenuBarIcon
@Environment(\.openWindow) var openWindow

let updaterDelegate: UpdateChannelUpdaterDelegate
let updaterController: SPUStandardUpdaterController

init() {
updaterDelegate = UpdateChannelUpdaterDelegate()
updaterController = SPUStandardUpdaterController(
startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
startingUpdater: true, updaterDelegate: updaterDelegate, userDriverDelegate: nil)

// Initialize the settings window controller with the updater controller
SettingsWindowController.shared.setUpdaterController(updaterController)
Expand Down
39 changes: 33 additions & 6 deletions boringNotch/components/Settings/SoftwareUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,19 @@
//

import SwiftUI
import Defaults
import Sparkle

final class UpdateChannelUpdaterDelegate: NSObject, SPUUpdaterDelegate {
@objc func feedURLString(for updater: SPUUpdater) -> String? {
Defaults[.updateChannel].feedURLString
}

@objc func allowedChannels(for updater: SPUUpdater) -> Set<String> {
Defaults[.updateChannel].allowedSparkleChannels
}
}

final class CheckForUpdatesViewModel: ObservableObject {
@Published var canCheckForUpdates = false

Expand Down Expand Up @@ -37,6 +48,7 @@ struct CheckForUpdatesView: View {
struct UpdaterSettingsView: View {
private let updater: SPUUpdater

@Default(.updateChannel) private var updateChannel
@State private var automaticallyChecksForUpdates: Bool
@State private var automaticallyDownloadsUpdates: Bool

Expand All @@ -47,21 +59,36 @@ struct UpdaterSettingsView: View {
}

var body: some View {
Section {
Section(
header: HStack {
Text("Software updates")
},
footer: Text(
NSLocalizedString(
"Stable and Beta come from official releases. Main and Dev use nightly builds from those branches.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about making nightly builds available here. Especially without any testing, these nightly builds will often be completely broken, so I feel like a seprating this might make more sense, but I'm not sure yet

comment: "Software updates channel footer"
)
)
) {
Picker(
NSLocalizedString("Update channel", comment: "Software updates channel picker label"),
selection: $updateChannel
) {
ForEach(UpdateChannel.allCases) { channel in
Text(channel.title).tag(channel)
}
}

Toggle("Automatically check for updates", isOn: $automaticallyChecksForUpdates)
.onChange(of: automaticallyChecksForUpdates) { _, newValue in
updater.automaticallyChecksForUpdates = newValue
}

Toggle("Automatically download updates", isOn: $automaticallyDownloadsUpdates)
.disabled(!automaticallyChecksForUpdates)
.onChange(of: automaticallyDownloadsUpdates) { _, newValue in
updater.automaticallyDownloadsUpdates = newValue
}
} header: {
HStack {
Text("Software updates")
}
}
}
}
49 changes: 49 additions & 0 deletions boringNotch/models/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,61 @@ enum OSDControlSource: String, CaseIterable, Identifiable, Defaults.Serializable
}
}

enum UpdateChannel: String, CaseIterable, Identifiable, Defaults.Serializable {
case stable
case beta
case main
case dev

var id: String { rawValue }

var title: String {
switch self {
case .stable:
return NSLocalizedString("Stable", comment: "Update channel: stable")
case .beta:
return NSLocalizedString("Beta", comment: "Update channel: beta")
case .main:
return NSLocalizedString("Main (Nightly)", comment: "Update channel: main nightly")
case .dev:
return NSLocalizedString("Dev (Nightly)", comment: "Update channel: dev nightly")
}
}

var feedURLString: String {
switch self {
case .stable:
return "https://TheBoredTeam.github.io/boring.notch/appcast.xml"
case .beta:
return "https://TheBoredTeam.github.io/boring.notch/appcast.xml"
case .main:
return "https://raw.githubusercontent.com/TheBoredTeam/boring.notch/main/updater/appcast-main.xml"
case .dev:
return "https://raw.githubusercontent.com/TheBoredTeam/boring.notch/dev/updater/appcast-dev.xml"
}
}

var allowedSparkleChannels: Set<String> {
switch self {
case .stable:
return []
case .beta:
return ["beta"]
case .main:
return ["main"]
case .dev:
return ["dev"]
}
}
}

extension Defaults.Keys {
// MARK: General
static let menubarIcon = Key<Bool>("menubarIcon", default: true)
static let showOnAllDisplays = Key<Bool>("showOnAllDisplays", default: false)
static let automaticallySwitchDisplay = Key<Bool>("automaticallySwitchDisplay", default: true)
static let releaseName = Key<String>("releaseName", default: "Flying Rabbit 🐇🪽")
static let updateChannel = Key<UpdateChannel>("updateChannel", default: .stable)

// MARK: Behavior
static let minimumHoverDuration = Key<TimeInterval>("minimumHoverDuration", default: 0.3)
Expand Down