From be82b2eb6759ffc04e6df190b90c34e2aef7d2d3 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Thu, 6 Aug 2026 10:17:19 -0400 Subject: [PATCH 1/4] [ci] Exec the cask we just installed, and fail on a hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing is not evidence the thing runs. tacet#25 shipped a cask that installed perfectly, passed tap-syntax, style, audit and the existing install/uninstall step, and then hung in _dyld_start on first exec — casks quarantine by default and nothing launches these bundles through LaunchServices, so the consent gate had nobody to answer it. Every check was green and it was broken for every user. --help is the cheapest proof a bundle reaches its own main(): a quarantine wedge blocks in the dynamic loader and never gets there. Bounded by background-and-poll rather than timeout(1), because a stock macOS runner has neither timeout nor gtimeout — depending on coreutils would leave the check silently unbounded on the one platform it protects. Same reasoning as run_bounded() in tacet's install-server.sh. The failure mode here is a hang, so an unbounded check would burn the job timeout instead of naming the problem. The cask comment is touched deliberately: the step only runs when Casks/ changes, so a workflow-only PR would not exercise its own new code. Solves: green CI on a cask that cannot start Tests: bounded-exec logic verified locally against exit 0, exit 1, a 30s hang (rc=124), and the real tacet binary; this PR's own CI run exercises it against the published 0.1.2 cask --- .github/workflows/tests.yml | 49 +++++++++++++++++++++++++++++++++++++ Casks/tacet.rb | 4 ++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6958b6d..443d817 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -70,6 +70,55 @@ jobs: cask="$(basename "$path" .rb)" echo "==> Installing drycodeworks/tap/$cask" brew install --cask "drycodeworks/tap/$cask" + + # Installing is not evidence the thing runs. DRYCodeWorks/tacet#25 + # shipped a cask that installed perfectly, passed every check + # above, and then hung in _dyld_start on first exec — casks + # quarantine by default, and nothing ever launches these bundles + # through LaunchServices, so the consent gate had nobody to answer + # it. Green CI, broken for every user. + # + # So exec what we installed. --help is the cheapest proof a bundle + # reaches its own main(): a quarantine wedge blocks in the dynamic + # loader and never gets there. Bounded, because that failure mode + # is a hang rather than a crash — unbounded, it would burn the job + # timeout instead of naming the problem. + for app in /Applications/*.app; do + [[ -d "$app" ]] || continue + exe_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \ + "$app/Contents/Info.plist" 2>/dev/null)" || continue + exe="$app/Contents/MacOS/$exe_name" + [[ -x "$exe" ]] || continue + echo "==> Executing $exe --help" + # Background-and-poll rather than timeout(1): a stock macOS + # runner has neither timeout nor gtimeout, so depending on + # coreutils would leave this silently unbounded on exactly the + # platform it protects. Same reasoning as run_bounded() in + # tacet's install-server.sh. + rc=0 + "$exe" --help >/dev/null 2>&1 & + pid=$! + waited=0 + while kill -0 "$pid" 2>/dev/null; do + if (( waited >= 30 )); then + kill -9 "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + rc=124 + break + fi + sleep 1 + waited=$((waited + 1)) + done + [[ $rc -eq 124 ]] || { wait "$pid"; rc=$?; } + [[ $rc -eq 0 ]] && continue + if [[ $rc -eq 124 ]]; then + echo "::error::$cask hangs on exec (rc=124) — likely a quarantine wedge, see DRYCodeWorks/tacet#25" + else + echo "::error::$cask exited $rc on --help" + fi + exit 1 + done + brew uninstall --cask "drycodeworks/tap/$cask" done <<< "$changed" diff --git a/Casks/tacet.rb b/Casks/tacet.rb index 70331de..efd60a4 100644 --- a/Casks/tacet.rb +++ b/Casks/tacet.rb @@ -29,7 +29,9 @@ # the fact does not recover it, and neither does replacing the file — only a # different path does. So this has to run before anything execs the binary. # - # See DRYCodeWorks/tacet#25 for the isolation. + # See DRYCodeWorks/tacet#25 for the isolation. CI enforces that this keeps + # working: the cask job execs the installed bundle with --help and fails the + # build on a hang, which is the signature of this bug coming back. postflight do system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{appdir}/Tacet.app"] From 5c79916ac0de459e71bb7ccff6b94a8088900b93 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Thu, 6 Aug 2026 10:23:43 -0400 Subject: [PATCH 2/4] [ci] Exec only the apps the cask installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version walked all of /Applications, so on a runner it execed Firefox and "Google Chrome for Testing" before ever reaching the cask's own bundle. Chrome for Testing never exits on --help, so the check failed every cask PR and attributed the hang to the cask under test — a false positive that would have taught everyone to ignore it. Snapshot /Applications before install and diff after, so only what this cask added gets exec'd. The error now names the bundle, not just the cask, because a cask can ship more than one app. Solves: false positive from runner-preinstalled apps Tests: simulated locally against three cases — good app only (pass), good app plus a bundle that sleeps forever (fails 1, names the hanging bundle), and a cask that installs no .app (pass) --- .github/workflows/tests.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 443d817..bf48103 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,7 +69,14 @@ jobs: [[ -n "$path" ]] || continue cask="$(basename "$path" .rb)" echo "==> Installing drycodeworks/tap/$cask" + # Snapshot first so we can exec ONLY what this cask added. A runner + # image ships its own apps, and some of them never exit on --help: + # "Google Chrome for Testing" hangs, which made a first version of + # this check fail every cask PR and blame the cask. + before="$(ls -1d /Applications/*.app 2>/dev/null | sort)" brew install --cask "drycodeworks/tap/$cask" + after="$(ls -1d /Applications/*.app 2>/dev/null | sort)" + installed="$(comm -13 <(echo "$before") <(echo "$after"))" # Installing is not evidence the thing runs. DRYCodeWorks/tacet#25 # shipped a cask that installed perfectly, passed every check @@ -83,8 +90,11 @@ jobs: # loader and never gets there. Bounded, because that failure mode # is a hang rather than a crash — unbounded, it would burn the job # timeout instead of naming the problem. - for app in /Applications/*.app; do - [[ -d "$app" ]] || continue + if [[ -z "$installed" ]]; then + echo "==> $cask added no .app to /Applications; nothing to exec." + fi + while read -r app; do + [[ -n "$app" && -d "$app" ]] || continue exe_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \ "$app/Contents/Info.plist" 2>/dev/null)" || continue exe="$app/Contents/MacOS/$exe_name" @@ -112,12 +122,12 @@ jobs: [[ $rc -eq 124 ]] || { wait "$pid"; rc=$?; } [[ $rc -eq 0 ]] && continue if [[ $rc -eq 124 ]]; then - echo "::error::$cask hangs on exec (rc=124) — likely a quarantine wedge, see DRYCodeWorks/tacet#25" + echo "::error::$cask: $app hangs on exec (rc=124) — likely a quarantine wedge, see DRYCodeWorks/tacet#25" else - echo "::error::$cask exited $rc on --help" + echo "::error::$cask: $app exited $rc on --help" fi exit 1 - done + done <<< "$installed" brew uninstall --cask "drycodeworks/tap/$cask" done <<< "$changed" From 0704339de9b4e041b7dea8c64d1564ec14f829e2 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Thu, 6 Aug 2026 10:28:08 -0400 Subject: [PATCH 3/4] [ci] Use find, not ls, to snapshot /Applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brew style runs actionlint over the workflows, and actionlint fails on shellcheck's SC2012 even at info level. Linting Casks/tacet.rb alone missed it; brew style drycodeworks/tap is the check that matches CI. Solves: --only-tap-syntax failing on the cask exec check Tests: brew style drycodeworks/tap — 3 files, no offenses --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bf48103..cc815ea 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -73,9 +73,9 @@ jobs: # image ships its own apps, and some of them never exit on --help: # "Google Chrome for Testing" hangs, which made a first version of # this check fail every cask PR and blame the cask. - before="$(ls -1d /Applications/*.app 2>/dev/null | sort)" + before="$(find /Applications -maxdepth 1 -name '*.app' | sort)" brew install --cask "drycodeworks/tap/$cask" - after="$(ls -1d /Applications/*.app 2>/dev/null | sort)" + after="$(find /Applications -maxdepth 1 -name '*.app' | sort)" installed="$(comm -13 <(echo "$before") <(echo "$after"))" # Installing is not evidence the thing runs. DRYCodeWorks/tacet#25 From 4e696e174f0eb607314e93da11265714195d7c1b Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Thu, 6 Aug 2026 10:33:31 -0400 Subject: [PATCH 4/4] [tacet] Declare arm64, and let CI respect a cask's arch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published bundle is Mach-O thin (arm64), but the cask constrained only macOS version — so brew installed it on an Intel Mac and the binary could not execute. Rosetta does not cover this direction. The exec check added in this PR caught it on the macos-15-intel runner on its first real run, which is the first evidence any of this tooling has produced. The workflow now reads depends_on arch and skips a cask the runner cannot run, because a legitimate single-arch cask is not a defect on the other half of the matrix. Homebrew normalises the token to arm/intel rather than arm64/x86_64, so the comparison uses those. The arch query is a python one-liner because a multi-line heredoc inside a YAML block scalar must be indented to stay in the block, and python then rejects the uniform leading whitespace. Solves: arm64-only bundle installable on Intel Tests: brew style drycodeworks/tap clean; arch gate simulated against the real cask for both runner types — arm installs and execs, intel skips --- .github/workflows/tests.yml | 22 ++++++++++++++++++++++ Casks/tacet.rb | 5 +++++ 2 files changed, 27 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cc815ea..30e80b9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,6 +68,28 @@ jobs: while read -r path; do [[ -n "$path" ]] || continue cask="$(basename "$path" .rb)" + + # Respect `depends_on arch:`. The matrix runs both Intel and Apple + # Silicon, and a cask that legitimately declares one of them is not + # a failure on the other — brew would refuse the install and the + # step would report a defect that does not exist. + # Homebrew normalises the token: `depends_on arch: :arm64` comes + # back as "arm", not "arm64", and :x86_64 as "intel". Comparing + # against uname -m directly never matches. + case "$(uname -m)" in + arm64) here=arm ;; + *) here=intel ;; + esac + # One line on purpose: a multi-line python heredoc inside a YAML + # block scalar has to be indented to stay in the block, and python + # then rejects the uniform leading whitespace. + want="$(brew info --json=v2 --cask "drycodeworks/tap/$cask" 2>/dev/null \ + | python3 -c "import json,sys; d=json.load(sys.stdin)['casks'][0]; a=(d.get('depends_on') or {}).get('arch') or []; print(','.join(x.get('type','') if isinstance(x,dict) else str(x) for x in a))" 2>/dev/null || true)" + if [[ -n "$want" && ",$want," != *",$here,"* ]]; then + echo "==> $cask requires arch [$want]; this runner is $here. Skipping." + continue + fi + echo "==> Installing drycodeworks/tap/$cask" # Snapshot first so we can exec ONLY what this cask added. A runner # image ships its own apps, and some of them never exit on --help: diff --git a/Casks/tacet.rb b/Casks/tacet.rb index efd60a4..b8f18ed 100644 --- a/Casks/tacet.rb +++ b/Casks/tacet.rb @@ -13,6 +13,11 @@ strategy :github_latest end + # The published bundle is `Mach-O thin (arm64)`. Without this, brew installs + # it happily on an Intel Mac and the binary cannot execute — the cask's own + # CI exec check caught exactly that on the macos-15-intel runner. Rosetta + # does not help: it translates x86_64 to arm64, not the other way. + depends_on arch: :arm64 depends_on macos: :ventura app "Tacet.app"