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
90 changes: 86 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.revision.outputs.build }}
app: ${{ steps.changes.outputs.app }}
packaging: ${{ steps.changes.outputs.packaging }}
steps:
- name: Skip superseded pushes
id: revision
Expand All @@ -42,6 +44,25 @@ jobs:
else
echo "build=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v7
if: github.event_name == 'pull_request'
with:
fetch-depth: 2
persist-credentials: false
# A pull request compiles the release and draws the docs images only when it changes what they
# build from: the files scripts/unchanged-app.sh compares, or this workflow. Packaging reads
# only the scripts and resources. HEAD merges the pull request into its base, HEAD^.
- name: Find what the pull request changes
id: changes
if: github.event_name == 'pull_request'
run: |
changed=$(git diff --name-only HEAD^ HEAD)
if grep -qE '^(Sources/|Resources/|Package\.(swift|resolved)$|scripts/|\.github/workflows/ci\.yml$)' <<< "$changed"; then
echo "app=true" >> "$GITHUB_OUTPUT"
fi
if grep -qE '^(Resources/|scripts/|\.github/workflows/ci\.yml$)' <<< "$changed"; then
echo "packaging=true" >> "$GITHUB_OUTPUT"
fi

test:
name: Test
Expand All @@ -53,6 +74,14 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
# Parses each script with the shell its first line names, without running it.
- name: Check script syntax
shell: zsh -e {0}
run: |
for script in scripts/*.sh examples/**/*.sh; do
interpreter=$(sed -n '1s/^#!//p' $script)
$interpreter -n $script
done
- name: Identify Swift toolchain
id: swift
run: echo "version=$({ swift --version; xcodebuild -version; xcrun --show-sdk-version; } | shasum -a 256 | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"
Expand All @@ -69,10 +98,12 @@ jobs:
- name: Test
run: swift test --skip-build

# Runs on main, and on pull requests that change the app, so a release-only failure shows up
# before it merges and leaves main without a beta.
build:
name: Build release
needs: current
if: needs.current.outputs.build == 'true' && github.ref == 'refs/heads/main'
if: needs.current.outputs.build == 'true' && (github.ref == 'refs/heads/main' || needs.current.outputs.app == 'true')
runs-on: macos-26
timeout-minutes: 20
steps:
Expand All @@ -82,14 +113,22 @@ jobs:
- name: Identify Swift toolchain
id: swift
run: echo "version=$({ swift --version; xcodebuild -version; xcrun --show-sdk-version; } | shasum -a 256 | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
- uses: actions/cache/restore@v6
id: cache
with:
path: .build
key: ${{ runner.os }}-${{ runner.arch }}-release-${{ steps.swift.outputs.version }}-${{ hashFiles('Package.swift', 'Package.resolved') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-release-${{ steps.swift.outputs.version }}-${{ hashFiles('Package.swift', 'Package.resolved') }}-
- name: Compile release
run: swift build -c release --disable-keychain
# Pull requests start from main's cache and save none of their own, so they don't push main's
# out of the repository's 10 GB.
- uses: actions/cache/save@v6
if: github.event_name != 'pull_request' && steps.cache.outputs.cache-hit != 'true'
with:
path: .build
key: ${{ steps.cache.outputs.cache-primary-key }}
- name: Archive release binary
run: tar -cf release-build.tar -C .build/release StableBar Sparkle.framework -C "$GITHUB_WORKSPACE/.build/artifacts/sparkle/Sparkle" bin
- uses: actions/upload-artifact@v7
Expand All @@ -98,8 +137,9 @@ jobs:
path: release-build.tar
retention-days: 1

# Draws the images in docs/images from each push to main, so a broken render shows up before a
# stable release needs it. The release commits them beside CHANGELOG.md.
# Draws the images in docs/images from each push to main, and from pull requests that change the
# app, so a broken render shows up before a stable release needs it. The release commits them
# beside CHANGELOG.md.
docs-images:
name: Docs images
env:
Expand Down Expand Up @@ -128,6 +168,48 @@ jobs:
path: docs/images/*.webp
retention-days: 1

# Builds the DMG as the beta does, for pull requests that change the scripts, resources or this
# workflow. Pull requests get no Developer ID, so the app is ad-hoc signed; only the beta and
# release jobs sign and notarize.
package:
name: Package
env:
STABLEBAR_PREBUILT_DIR: release-build
VERSION: 0.0.0-pr.${{ github.event.pull_request.number }}
needs: [current, build]
if: github.event_name == 'pull_request' && needs.current.outputs.packaging == 'true'
runs-on: macos-26
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/download-artifact@v8
with:
name: release-build
- name: Extract release binary
run: |
mkdir release-build
tar -xf release-build.tar -C release-build
- name: Build the installer
run: |
brew install create-dmg
./scripts/set-version.sh "$VERSION" "$GITHUB_RUN_NUMBER"
STABLEBAR_RELEASE_BUILD=1 ./scripts/package.sh
- name: Check the installer
run: |
hdiutil verify dist/StableBar.dmg
volume=$(mktemp -d)
hdiutil attach dist/StableBar.dmg -nobrowse -readonly -mountpoint "$volume"
app="$volume/StableBar.app"
codesign --verify --deep --strict "$app"
stamped="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' -c 'Print :CFBundleVersion' "$app/Contents/Info.plist" | paste -sd ' ' -)"
if [[ "$stamped" != "$VERSION $GITHUB_RUN_NUMBER" ]]; then
echo "::error::The app is version $stamped, not $VERSION $GITHUB_RUN_NUMBER."
exit 1
fi
"$app/Contents/MacOS/StableBar" --validate Resources/config.json

commits:
name: Commit messages
if: github.event_name == 'pull_request'
Expand Down
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,16 @@ There is no web view, custom drawing loop or private window-server API. Styling
1. **Start from an issue** for every `fix`, `feat` or `perf` change, using the issue forms, and link it from the pull request. The [triage skill](#agent-skills) checks whether a report is already tracked, fixed or supported. Other changes, such as CI, tests or docs, can go straight to a pull request.
2. **Branch from `main` and open a pull request.** Nobody pushes to `main` directly. Fill in the template: the problem, the changes, how you tested them and how to merge. Upload screenshots of visible changes to the pull request instead of committing them.
3. **Keep one problem per pull request.** Unrelated fixes and cleanups get their own.
4. **Wait for CI.** `Test` and `Commit messages` must pass.
4. **Wait for CI.** `Test` and `Commit messages` must pass. Depending on what a pull request changes, `Build release`, `Docs images` and `Package` run too ([Commits and releases](#commits-and-releases) lists when). Fix them before merging: the same failure on `main` stops the next beta.
5. **Merge** with a merge commit or a rebase merge, so semantic-release sees each commit. Squash only when the title alone should reach the changelog.

Reviews are requested automatically from the people listed in [CODEOWNERS](.github/CODEOWNERS), but they are not required. Ask for one when a change is risky or outside your usual area.

## Commits and releases

Pull requests run `swift test` on macOS and check that every commit message, and the pull request title, follows [Conventional Commits](https://www.conventionalcommits.org). Squash merges use the title as the commit message.
Pull requests run `swift test` on macOS and check that every commit message, and the pull request title, follows [Conventional Commits](https://www.conventionalcommits.org). Squash merges use the title as the commit message. The tests load every configuration, module and package in `examples`, and the **Test** job parses each script in `scripts` and `examples` with the shell its first line names.

A pull request that changes `Sources`, `Resources`, `Package.swift`, `Package.resolved`, `scripts` or the CI workflow also compiles the release and draws the [docs images](#docs-images), as `main` does. If it changes `Resources`, `scripts` or the workflow, the **Package** job builds the DMG as a beta would, ad-hoc signed because pull requests get no Developer ID, and checks its signature, version and starter configuration.

[semantic-release](https://github.com/semantic-release/semantic-release) reads the commits since the last stable release and decides the next version:

Expand All @@ -97,7 +99,7 @@ Pull requests run `swift test` on macOS and check that every commit message, and
| `feat!:`, or a `BREAKING CHANGE:` footer | Major: 0.1.0 → 1.0.0 |
| `build:`, `chore:`, `ci:`, `docs:`, `refactor:`, `style:`, `test:` | None |

Queued pushes skip builds when `main` has already moved on. Tests and release compilation run in parallel, with separate Swift build caches for debug and release. Packaging uses the compiled binary from the same workflow run only after both jobs pass; signing, notarization and publication still happen in the release job.
Queued pushes skip builds when `main` has already moved on. Tests and release compilation run in parallel, with separate Swift build caches for debug and release. Pull requests restore `main`'s release cache but don't save their own. Packaging uses the compiled binary from the same workflow run only after both jobs pass; signing, notarization and publication still happen in the release job.

Both release channels come from `main`:

Expand All @@ -123,7 +125,7 @@ Release jobs set `STABLEBAR_RELEASE_BUILD=1` when packaging to preserve their st

## Docs images

`scripts/render-docs-images.sh` draws the images in `docs/images` from the app's own bar, popup and Settings views, with sample data and a fixed time, and writes them as WebP. It needs `cwebp`: `brew install webp`. The **Docs images** CI job draws them on every push to `main`, and a stable release commits them, so a change to the bar, popups or Settings doesn't need new images. To show something new, add a scene to [DocsImages.swift](Sources/StableBar/DocsImages.swift).
`scripts/render-docs-images.sh` draws the images in `docs/images` from the app's own bar, popup and Settings views, with sample data and a fixed time, and writes them as WebP. It needs `cwebp`: `brew install webp`. The **Docs images** CI job draws them on every push to `main` and on pull requests that change the app, and a stable release commits them, so a change to the bar, popups or Settings doesn't need new images. To show something new, add a scene to [DocsImages.swift](Sources/StableBar/DocsImages.swift).

## Agent skills

Expand Down
65 changes: 58 additions & 7 deletions Tests/StableBarTests/ModuleConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -428,13 +428,14 @@ private func writeConfiguration(_ json: String, to url: URL) throws {
#expect(configuration.providers["machine"]?.command == nil)
}

private func schemaProperties(_ definition: ConfigurationValue?) throws -> Set<String> {
Set(try (definition?.decoded([String: ConfigurationValue].self)["properties"] ?? .object([:])).decoded([String: ConfigurationValue].self).keys)
}

@Test func schemaListsTheSamePropertiesAsTheDecoders() throws {
let url = URL(filePath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
.appending(path: "stablebar.schema.json")
let schema = try JSONDecoder().decode([String: ConfigurationValue].self, from: Data(contentsOf: url))
func properties(_ definition: ConfigurationValue?) throws -> Set<String> {
Set(try (definition?.decoded([String: ConfigurationValue].self)["properties"] ?? .object([:])).decoded([String: ConfigurationValue].self).keys)
}
let definitions = try schema["definitions"]!.decoded([String: ConfigurationValue].self)
let decoders: [String: [CodingKey]] = [
"style": WidgetStyle.CodingKeys.allCases, "widget": WidgetConfiguration.CodingKeys.allCases, "bar": BarSettings.CodingKeys.allCases,
Expand All @@ -449,12 +450,21 @@ private func writeConfiguration(_ json: String, to url: URL) throws {
"packageSource": PackageSource.CodingKeys.allCases
]
for (name, keys) in decoders {
#expect(try properties(definitions[name]) == Set(keys.map(\.stringValue) + (name == "widget" ? ["template"] : [])), "\(name)")
#expect(try schemaProperties(definitions[name]) == Set(keys.map(\.stringValue) + (name == "widget" ? ["template"] : [])), "\(name)")
}
#expect(try properties(.object(schema)) == Set(Configuration.CodingKeys.allCases.map(\.stringValue)
#expect(try schemaProperties(.object(schema)) == Set(Configuration.CodingKeys.allCases.map(\.stringValue)
+ ["$schema", "tokens", "packages", "packageSources", "theme", "includes", "profiles", "profile", "widgetOrder"]))
}

@Test func packageSchemaListsTheSamePropertiesAsTheManifestDecoder() throws {
let url = URL(filePath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
.appending(path: "package.schema.json")
let schema = try JSONDecoder().decode([String: ConfigurationValue].self, from: Data(contentsOf: url))
#expect(try schemaProperties(.object(schema)) == Set(WidgetPackageManifest.CodingKeys.allCases.map(\.stringValue)))
let setting = try schema["definitions"]?.decoded([String: ConfigurationValue].self)["setting"]
#expect(try schemaProperties(setting) == Set(WidgetPackageSetting.CodingKeys.allCases.map(\.stringValue)))
}

@Test func menuBarItemsOpenOnHoverUnlessSetToOpenOnClick() throws {
let configuration = try JSONDecoder().decode(Configuration.self, from: Data(#"""
{"widgets":[{"id":"items","type":"menuBarItems","position":"right"},
Expand Down Expand Up @@ -489,8 +499,49 @@ private func writeConfiguration(_ json: String, to url: URL) throws {
#expect(interactive.defaults.states?["open"]?.borderWidth == 1)
#expect(interactive.widgets.first { $0.id == "application" }?.transition == .roll)
_ = try loader.load(repository.appending(path: "Resources/config.json"))
_ = try WidgetPackageStore(directory: repository.appending(path: "examples/packages"))
.inspect(repository.appending(path: "examples/packages/local-clock"))
}

/// Finds the examples itself, so a new one is checked without an entry here.
@Test func everyExampleLoadsBesideTheExamplePackages() throws {
let repository = URL(filePath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let examples = repository.appending(path: "examples")
let files = FileManager.default
let scratch = files.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? files.removeItem(at: scratch) }
let installed = scratch.appending(path: "packages")
let packages = WidgetPackageStore(directory: installed)
for package in try files.contentsOfDirectory(at: examples.appending(path: "packages"), includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
#expect(throws: Never.self, "packages/\(package.lastPathComponent)") { try packages.install(package) }
}
// Loads a configuration as StableBar --validate does, from a folder holding `sources` and the installed packages.
func validate(_ name: String, _ sources: [URL], configuration: String = "config.json", contents: String? = nil) {
let directory = scratch.appending(path: UUID().uuidString)
do {
try files.createDirectory(at: directory, withIntermediateDirectories: false)
try files.copyItem(at: installed, to: directory.appending(path: "packages"))
for source in sources { try files.copyItem(at: source, to: directory.appending(path: source.lastPathComponent)) }
if let contents { try Data(contents.utf8).write(to: directory.appending(path: configuration)) }
var loader = ConfigurationLoader()
loader.themesDirectory = repository.appending(path: "Resources/themes")
_ = try loader.load(directory.appending(path: configuration))
} catch {
Issue.record("\(name): \(errorMessage(error))")
}
}
for example in try files.contentsOfDirectory(at: examples, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles) {
let name = example.lastPathComponent
if name == "packages" { continue }
if name == "modules" {
// Saved beside a configuration and included, as the guide shows.
for module in try files.contentsOfDirectory(at: example, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
validate("modules/\(module.lastPathComponent)", [module], contents: #"{"includes":["\#(module.lastPathComponent)"],"widgets":[]}"#)
}
} else if try example.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true {
validate(name, try files.contentsOfDirectory(at: example, includingPropertiesForKeys: nil, options: .skipsHiddenFiles))
} else if example.pathExtension == "json" {
validate(name, [example], configuration: name)
}
}
}

@Test func schemaListsTheRegisteredSourceTypes() throws {
Expand Down
Loading