diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 446cb18..de71be9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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" @@ -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: @@ -82,7 +113,8 @@ 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 }} @@ -90,6 +122,13 @@ jobs: ${{ 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 @@ -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: @@ -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' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f3635f..4930492 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: @@ -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`: @@ -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 diff --git a/Tests/StableBarTests/ModuleConfigurationTests.swift b/Tests/StableBarTests/ModuleConfigurationTests.swift index f209a9f..3a280aa 100644 --- a/Tests/StableBarTests/ModuleConfigurationTests.swift +++ b/Tests/StableBarTests/ModuleConfigurationTests.swift @@ -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 { + 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 { - 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, @@ -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"}, @@ -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 {