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..d4ac552 --- /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: true + 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..be97a8e --- /dev/null +++ b/scripts/update_homebrew_cask.py @@ -0,0 +1,73 @@ +#!/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, flags=re.M + ) + cask, checksum_count = re.subn( + r'^ sha256 "[0-9a-f]{64}"$', + f' sha256 "{checksum}"', + cask, + 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..c9200f9 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,59 @@ 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('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'); @@ -147,6 +215,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/);