From 31df08a977411fb6544307a45a3b7183b8740eba Mon Sep 17 00:00:00 2001 From: vordenken <62395860+vordenken@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:31:06 +0200 Subject: [PATCH 1/2] Add Homebrew cask automation to release workflow Introduce automatic updates for the Homebrew cask on stable releases. Add update_homebrew_cask.py script to manage version and checksum updates from the release DMG. Integrate cask updates into the release workflow, triggered only for stable releases. Update documentation and tests to reflect the new automation. Add Homebrew installation instructions to README and expand test coverage for the cask updater. --- .github/workflows/build-release.yml | 14 +++++- .github/workflows/tests.yml | 1 + AGENTS.md | 4 +- BUILD.md | 6 +++ Casks/autopip.rb | 43 +++++++++++++++++ README.md | 12 +++++ scripts/update_homebrew_cask.py | 74 +++++++++++++++++++++++++++++ tests/release-pipeline.test.js | 53 +++++++++++++++++++++ 8 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 Casks/autopip.rb create mode 100644 scripts/update_homebrew_cask.py diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index dc2308f..c3b0bea 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -356,7 +356,9 @@ jobs: - name: Publish canonical appcast env: + CHANNEL: ${{ steps.release.outputs.channel }} TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} run: | git fetch origin main APPCAST_WORKTREE="$RUNNER_TEMP/appcast-main" @@ -366,12 +368,20 @@ jobs: cp "$RUNNER_TEMP/appcast.xml" "$APPCAST_WORKTREE/appcast.xml" git -C "$APPCAST_WORKTREE" add appcast.xml + if [ "$CHANNEL" = "stable" ]; then + python3 scripts/update_homebrew_cask.py \ + --cask "$APPCAST_WORKTREE/Casks/autopip.rb" \ + --version "$VERSION" \ + --archive "$RUNNER_TEMP/AutoPiP.dmg" + git -C "$APPCAST_WORKTREE" add Casks/autopip.rb + fi + if git -C "$APPCAST_WORKTREE" diff --cached --quiet; then - echo "appcast.xml is already current" + echo "Release metadata is already current" exit 0 fi - git -C "$APPCAST_WORKTREE" commit -m "chore: update appcast for ${TAG}" + git -C "$APPCAST_WORKTREE" commit -m "chore: update release metadata for ${TAG}" git -C "$APPCAST_WORKTREE" push origin HEAD:main - name: Cleanup keychain diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3ecde48..01a48db 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,6 +8,7 @@ on: - '.github/actionlint.yaml' - '.github/workflows/build-release.yml' - '.github/workflows/tests.yml' + - 'Casks/**' - 'AutoPiP/**' - 'AutoPiP Extension/**' - 'AutoPiPTests/**' diff --git a/AGENTS.md b/AGENTS.md index 589083e..dd76c35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,8 @@ The release workflow: 4. Archives and signs the app, then creates the DMG. 5. Signs the DMG with Sparkle's EdDSA tool. 6. Publishes the GitHub Release. -7. Updates the canonical appcast on `main`. +7. Updates the canonical appcast and, for stable releases, the Homebrew cask on + `main`. Release jobs are globally serialized because all branches share tags and one appcast. Running releases are not cancelled. GitHub Actions use floating major @@ -253,6 +254,7 @@ Perform the applicable Safari smoke tests before release: - Release source of truth: `semver.txt` - Release orchestration: `.github/workflows/build-release.yml` - Appcast transformation: `scripts/update_appcast.py` +- Homebrew cask transformation: `scripts/update_homebrew_cask.py` - Automated checks: `.github/workflows/tests.yml`, `tests/`, and `AutoPiPTests/` --- diff --git a/BUILD.md b/BUILD.md index dc8ad43..2538e68 100644 --- a/BUILD.md +++ b/BUILD.md @@ -56,8 +56,14 @@ The workflow automatically: - Signs the DMG with the code signing certificate and Sparkle EdDSA - Creates a Git tag and GitHub Release with changelog + installation instructions - Updates the canonical `appcast.xml` on `main` after publishing the release +- Updates the Homebrew cask version and checksum from the stable release DMG - Keeps all stable feed entries and the five newest beta entries +The Homebrew cask is updated only for stable releases. No additional release step +is required: changing `semver.txt` and merging to `main` remains the source of +truth for the GitHub release, Sparkle appcast, and Homebrew cask. Beta releases do +not modify the cask. + Release jobs are serialized because all branches share one tag namespace and one canonical appcast. Running jobs are never cancelled, and existing tags are never moved. The workflow downloads the Sparkle tools at the version pinned by SwiftPM diff --git a/Casks/autopip.rb b/Casks/autopip.rb new file mode 100644 index 0000000..5bd2a8d --- /dev/null +++ b/Casks/autopip.rb @@ -0,0 +1,43 @@ +cask "autopip" do + version "2.1.0" + sha256 "1f7a0f647c577b44e51d0ac5e450804369ada9d522d75fe4371855d5e768f01a" + + url "https://github.com/vordenken/AutoPiP/releases/download/v#{version}/AutoPiP.dmg" + name "AutoPiP" + desc "Safari extension for automatic Picture-in-Picture video playback" + homepage "https://github.com/vordenken/AutoPiP" + + livecheck do + url "https://raw.githubusercontent.com/vordenken/AutoPiP/main/appcast.xml" + strategy :sparkle do |items| + items.find { |item| item.channel.nil? }&.short_version + end + end + + auto_updates true + depends_on macos: :ventura + + app "AutoPiP.app" + + preflight_steps do + run "/usr/bin/xattr", + args: ["-dr", "com.apple.quarantine", "{{staged_path}}/AutoPiP.app"], + must_succeed: false + end + + uninstall quit: "com.vd.AutoPiP" + + zap trash: [ + "~/Library/Application Scripts/com.vd.AutoPiP", + "~/Library/Application Scripts/com.vd.AutoPiP.Extension", + "~/Library/Containers/com.vd.AutoPiP", + "~/Library/Containers/com.vd.AutoPiP.Extension", + ] + + caveats <<~EOS + AutoPiP is not notarized. This cask removes its quarantine attribute during + installation so macOS does not block the first launch. + + Launch AutoPiP once, then enable the extension in Safari Settings > Extensions. + EOS +end diff --git a/README.md b/README.md index 15c5af2..1a88a3c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,18 @@ A Safari extension that automatically enables Picture-in-Picture (PiP) mode for ## 🚀 Quick Start +### Homebrew + +```bash +brew tap vordenken/autopip https://github.com/vordenken/AutoPiP +brew install --cask vordenken/autopip/autopip +``` + +The Homebrew cask removes the quarantine attribute during installation. Launch +AutoPiP once, then enable it in Safari → Settings → Extensions. + +### Manual installation + > **⚠️ macOS Gatekeeper:** AutoPiP is not notarized by Apple. macOS will block both the DMG and the app with a security warning — this is expected. Follow the steps below to allow them. Once installed, **updates via Sparkle work without this workaround**. 1. Download the latest `AutoPiP.dmg` from [Releases](https://github.com/vordenken/AutoPiP/releases) diff --git a/scripts/update_homebrew_cask.py b/scripts/update_homebrew_cask.py new file mode 100644 index 0000000..fd4ec30 --- /dev/null +++ b/scripts/update_homebrew_cask.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +import argparse +import hashlib +import os +import re +import stat +import tempfile +from pathlib import Path + + +def parse_arguments(): + parser = argparse.ArgumentParser(description="Update the Homebrew cask release") + parser.add_argument("--cask", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--archive", type=Path, required=True) + return parser.parse_args() + + +def archive_sha256(path): + digest = hashlib.sha256() + with path.open("rb") as archive: + for chunk in iter(lambda: archive.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def update_cask(cask_path, version, archive_path): + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise ValueError(f"Invalid version: {version}") + if not archive_path.is_file(): + raise ValueError(f"Archive does not exist: {archive_path}") + + cask = cask_path.read_text(encoding="utf-8") + checksum = archive_sha256(archive_path) + cask, version_count = re.subn( + r'^ version "[^"]+"$', f' version "{version}"', cask, count=1, flags=re.M + ) + cask, checksum_count = re.subn( + r'^ sha256 "[0-9a-f]{64}"$', + f' sha256 "{checksum}"', + cask, + count=1, + flags=re.M, + ) + if version_count != 1 or checksum_count != 1: + raise ValueError("Cask must contain exactly one version and SHA-256 stanza") + + mode = stat.S_IMODE(cask_path.stat().st_mode) + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=cask_path.parent, + delete=False, + prefix="autopip-cask-", + ) as temporary_file: + temporary_file.write(cask) + temporary_path = Path(temporary_file.name) + os.chmod(temporary_path, mode) + os.replace(temporary_path, cask_path) + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() + + +def main(): + args = parse_arguments() + update_cask(args.cask, args.version, args.archive) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/release-pipeline.test.js b/tests/release-pipeline.test.js index 46ae7e4..2e6572f 100644 --- a/tests/release-pipeline.test.js +++ b/tests/release-pipeline.test.js @@ -1,4 +1,5 @@ const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -7,6 +8,7 @@ const test = require('node:test'); const repositoryRoot = path.join(__dirname, '..'); const updateAppcast = path.join(repositoryRoot, 'scripts', 'update_appcast.py'); +const updateHomebrewCask = path.join(repositoryRoot, 'scripts', 'update_homebrew_cask.py'); const workflowPath = path.join(repositoryRoot, '.github', 'workflows', 'build-release.yml'); const packageResolutionPath = path.join( repositoryRoot, @@ -72,6 +74,19 @@ function assertValidXml(appcast) { assert.equal(result.status, 0, result.stderr); } +function createCaskFixture() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'autopip-cask-')); + const cask = path.join(directory, 'autopip.rb'); + const archive = path.join(directory, 'AutoPiP.dmg'); + fs.writeFileSync(cask, `cask "autopip" do + version "2.1.0" + sha256 "${'0'.repeat(64)}" +end +`); + fs.writeFileSync(archive, 'AutoPiP release fixture\n'); + return { directory, cask, archive }; +} + test('beta update keeps five newest betas and every stable release', (context) => { const fixture = createFixture([ appcastItem('2.1.0-beta6', { beta: true, build: 46 }), @@ -128,6 +143,41 @@ test('appcast updater rejects a tag that does not match the release channel', (c assert.match(result.stderr, /does not match stable version/); }); +test('Homebrew cask updater writes the release version and archive checksum', (context) => { + const fixture = createCaskFixture(); + context.after(() => fs.rmSync(fixture.directory, { recursive: true, force: true })); + + const result = spawnSync('python3', [ + updateHomebrewCask, + '--cask', fixture.cask, + '--version', '2.2.0', + '--archive', fixture.archive + ], { encoding: 'utf8' }); + + assert.equal(result.status, 0, result.stderr); + const output = fs.readFileSync(fixture.cask, 'utf8'); + const checksum = crypto.createHash('sha256').update(fs.readFileSync(fixture.archive)).digest('hex'); + assert.match(output, /version "2\.2\.0"/); + assert.match(output, new RegExp(`sha256 "${checksum}"`)); +}); + +test('Homebrew cask updater rejects invalid versions without changing the cask', (context) => { + const fixture = createCaskFixture(); + context.after(() => fs.rmSync(fixture.directory, { recursive: true, force: true })); + const before = fs.readFileSync(fixture.cask, 'utf8'); + + const result = spawnSync('python3', [ + updateHomebrewCask, + '--cask', fixture.cask, + '--version', '2.2.0-beta1', + '--archive', fixture.archive + ], { encoding: 'utf8' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Invalid version/); + assert.equal(fs.readFileSync(fixture.cask, 'utf8'), before); +}); + test('release workflow preserves immutable and serialized publishing', () => { const workflow = fs.readFileSync(workflowPath, 'utf8'); @@ -147,6 +197,9 @@ test('release workflow preserves immutable and serialized publishing', () => { assert.match(workflow, /create-dmg\/archive\/refs\/tags\/v\$\{CREATE_DMG_VERSION\}/); assert.match(workflow, /SPARKLE_VERSION: '2\.9\.3'/); assert.match(workflow, /python3 scripts\/update_appcast\.py/); + assert.match(workflow, /if \[ "\$CHANNEL" = "stable" \]; then/); + assert.match(workflow, /python3 scripts\/update_homebrew_cask\.py/); + assert.match(workflow, /--archive "\$RUNNER_TEMP\/AutoPiP\.dmg"/); assert.match(workflow, /git tag --points-at.*GITHUB_SHA/s); assert.match(workflow, /Reusing.*for a retry/); assert.match(workflow, /tail -1 \|\| true/); From 6a9be71e8909d69fe7432aafcd0164131eec3511 Mon Sep 17 00:00:00 2001 From: vordenken <62395860+vordenken@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:41:04 +0200 Subject: [PATCH 2/2] Fix Homebrew cask preflight validation and remove redundant re.subn count parameters - Change preflight xattr command must_succeed from false to true - Remove explicit count=1 parameter from re.subn calls (handled by count validation) - Add test case for duplicate stanza detection in Homebrew cask updater --- Casks/autopip.rb | 2 +- scripts/update_homebrew_cask.py | 3 +-- tests/release-pipeline.test.js | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Casks/autopip.rb b/Casks/autopip.rb index 5bd2a8d..d4ac552 100644 --- a/Casks/autopip.rb +++ b/Casks/autopip.rb @@ -22,7 +22,7 @@ preflight_steps do run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "{{staged_path}}/AutoPiP.app"], - must_succeed: false + must_succeed: true end uninstall quit: "com.vd.AutoPiP" diff --git a/scripts/update_homebrew_cask.py b/scripts/update_homebrew_cask.py index fd4ec30..be97a8e 100644 --- a/scripts/update_homebrew_cask.py +++ b/scripts/update_homebrew_cask.py @@ -34,13 +34,12 @@ def update_cask(cask_path, version, archive_path): cask = cask_path.read_text(encoding="utf-8") checksum = archive_sha256(archive_path) cask, version_count = re.subn( - r'^ version "[^"]+"$', f' version "{version}"', cask, count=1, flags=re.M + r'^ version "[^"]+"$', f' version "{version}"', cask, flags=re.M ) cask, checksum_count = re.subn( r'^ sha256 "[0-9a-f]{64}"$', f' sha256 "{checksum}"', cask, - count=1, flags=re.M, ) if version_count != 1 or checksum_count != 1: diff --git a/tests/release-pipeline.test.js b/tests/release-pipeline.test.js index 2e6572f..c9200f9 100644 --- a/tests/release-pipeline.test.js +++ b/tests/release-pipeline.test.js @@ -178,6 +178,24 @@ test('Homebrew cask updater rejects invalid versions without changing the cask', assert.equal(fs.readFileSync(fixture.cask, 'utf8'), before); }); +test('Homebrew cask updater rejects duplicate stanzas without changing the cask', (context) => { + const fixture = createCaskFixture(); + context.after(() => fs.rmSync(fixture.directory, { recursive: true, force: true })); + fs.appendFileSync(fixture.cask, ` version "2.1.0"\n sha256 "${'0'.repeat(64)}"\n`); + const before = fs.readFileSync(fixture.cask, 'utf8'); + + const result = spawnSync('python3', [ + updateHomebrewCask, + '--cask', fixture.cask, + '--version', '2.2.0', + '--archive', fixture.archive + ], { encoding: 'utf8' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /exactly one version and SHA-256 stanza/); + assert.equal(fs.readFileSync(fixture.cask, 'utf8'), before); +}); + test('release workflow preserves immutable and serialized publishing', () => { const workflow = fs.readFileSync(workflowPath, 'utf8');