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
14 changes: 12 additions & 2 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
- '.github/actionlint.yaml'
- '.github/workflows/build-release.yml'
- '.github/workflows/tests.yml'
- 'Casks/**'
- 'AutoPiP/**'
- 'AutoPiP Extension/**'
- 'AutoPiPTests/**'
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/`

---
Expand Down
6 changes: 6 additions & 0 deletions BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions Casks/autopip.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +22 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Homebrew does not recognize preflight_steps as a cask stanza, so brew style/cask loading fails before installation and the cask cannot be installed.

Suggested fix: Use Homebrew's supported preflight do stanza and invoke the command with the supported system_command API.

Suggested change
preflight_steps do
run "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "{{staged_path}}/AutoPiP.app"],
must_succeed: false
end
preflight do
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "{{staged_path}}/AutoPiP.app"],
must_succeed: false
end

Comment thread
sourcery-ai[bot] marked this conversation as resolved.

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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions scripts/update_homebrew_cask.py
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

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()
71 changes: 71 additions & 0 deletions tests/release-pipeline.test.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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,
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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');

Expand All @@ -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/);
Expand Down