Skip to content

Release sss_code/v0.3.1 #64

Release sss_code/v0.3.1

Release sss_code/v0.3.1 #64

Workflow file for this run

name: Release
run-name: Release ${{ github.event.inputs.tag || github.ref_name }}
# Two parallel tag streams trigger this workflow:
# sss_cli/v0.2.1 → releases the `sss` binary (sss_cli)
# sss_code/v0.3.0 → releases the `sss_code` binary
#
# Library crates (sss_core, sss_lib, sss_ocr, sss_capture, sss_capture_ui)
# have `tag = false` in their per-crate release metadata so cargo-release
# bumps their version + commit but never tags them — only the two binary
# crates fan into this workflow.
#
# `workflow_dispatch` lets you force a release from a tag too.
on:
push:
tags:
- "sss_cli/v*"
- "sss_code/v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to release (e.g. sss_cli/v0.2.1 or sss_code/v0.3.0)."
required: true
type: string
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# Resolve which binary we're releasing + which version, once for all
# downstream jobs to consume via `needs.plan.outputs.*`.
plan:
name: Plan release
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.plan.outputs.tag }}
binary: ${{ steps.plan.outputs.binary }}
version: ${{ steps.plan.outputs.version }}
release_name: ${{ steps.plan.outputs.release_name }}
crate_dir: ${{ steps.plan.outputs.crate_dir }}
tag_pattern: ${{ steps.plan.outputs.tag_pattern }}
cliff_paths: ${{ steps.plan.outputs.cliff_paths }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
fetch-depth: 0
- name: Resolve target
id: plan
run: |
tag="${{ github.event.inputs.tag || github.ref_name }}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
case "$tag" in
sss_code/v*)
binary=sss_code
version="${tag#sss_code/v}"
crate_dir=sss_code
tag_pattern='sss_code/v[0-9]+\.[0-9]+\.[0-9]+'
# sss_code depends on sss_lib (rendering pipeline) and
# sss_core (shared primitives). Changes in either land in
# the same binary, so include them in the changelog.
cliff_paths='--include-path crates/sss_code/** --include-path crates/sss_lib/** --include-path crates/sss_core/**'
crate=crates/sss_code/Cargo.toml ;;
sss_cli/v*)
binary=sss
version="${tag#sss_cli/v}"
crate_dir=sss_cli
tag_pattern='sss_cli/v[0-9]+\.[0-9]+\.[0-9]+'
# sss_cli pulls every workspace crate (sss_lib for render,
# sss_core primitives, sss_capture + sss_capture_ui for the
# selector/annotator, sss_ocr for text recognition). All of
# them ship inside the `sss` binary, so all are in scope.
cliff_paths='--include-path crates/sss_cli/** --include-path crates/sss_lib/** --include-path crates/sss_core/** --include-path crates/sss_capture/** --include-path crates/sss_capture_ui/** --include-path crates/sss_ocr/**'
crate=crates/sss_cli/Cargo.toml ;;
*)
echo "::error::tag '$tag' does not match sss_cli/v* or sss_code/v*"
exit 1 ;;
esac
echo "binary=$binary" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "release_name=$binary v$version" >> "$GITHUB_OUTPUT"
echo "crate_dir=$crate_dir" >> "$GITHUB_OUTPUT"
echo "tag_pattern=$tag_pattern" >> "$GITHUB_OUTPUT"
echo "cliff_paths=$cliff_paths" >> "$GITHUB_OUTPUT"
crate_ver=$(awk -F\" '/^version = /{print $2; exit}' "$crate")
if [ "$crate_ver" != "$version" ]; then
echo "::error::$crate version ($crate_ver) does not match tag ($version)"
exit 1
fi
# Per-runner slice builds. Each runner only emits what it can produce
# natively (or via pkgsCross on the linux host); the publish job stitches
# every artifact into a single release.
#
# `variant` axis: every `sss` (sss_cli) tag releases TWO sets of bundles
# in parallel — the full build (default features, ships libonnxruntime +
# CUDA stack inside the bundle) and a `no-ocr` build (`--no-default-features`,
# no OCR code, distro packages list `onnxruntime` as a recommendation).
# `sss_code` only ships in one flavour; the matrix is filtered below.
bundles:
name: Build bundles (${{ matrix.slice.label }} ${{ matrix.variant }})
needs: plan
runs-on: ${{ matrix.slice.runner }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
slice:
- label: linux-x86_64
runner: ubuntu-latest
slice: linux-x86_64
- label: linux-aarch64
runner: ubuntu-24.04-arm
slice: linux-aarch64
- label: macos-aarch64
runner: macos-14
slice: darwin-aarch64
variant: [system, nvidia, rocm, noocr]
# Static excludes drop combinations that cannot produce any
# artifact, so the matrix doesn't spawn a runner just to no-op.
# Per-variant slice restrictions:
# - any slice × `nvidia` on macOS → no CUDA stack on Apple
# - any slice × `rocm` except linux-x86_64 → ROCm only on Linux-x86_64
# `sss_code` × {nvidia, rocm, noocr} can't be expressed here (the
# binary name resolves only at job time from `needs.plan.outputs`);
# those are still gated via the `Plan slice` step below.
# x86_64-darwin slice dropped: nixpkgs deprecating it (26.05 is
# the last supported), GitHub macos-13 runners queue indefinitely
# on free-tier quotas. aarch64-darwin covers modern Mac users.
exclude:
- slice:
label: macos-aarch64
runner: macos-14
slice: darwin-aarch64
variant: nvidia
- slice:
label: macos-aarch64
runner: macos-14
slice: darwin-aarch64
variant: rocm
- slice:
label: linux-aarch64
runner: ubuntu-24.04-arm
slice: linux-aarch64
variant: rocm
steps:
- name: Plan slice
id: pin
shell: bash
run: |
# The matrix `exclude:` already prunes combinations that can't
# produce any artifact. This step handles the dynamic gate: if
# the tag belongs to `sss_code`, the only eligible variant is
# `system` — `nvidia`/`rocm`/`noocr` are sss-only.
eligible=true
variant="${{ matrix.variant }}"
binary="${{ needs.plan.outputs.binary }}"
slice="${{ matrix.slice.slice }}"
if [ "$binary" != "sss" ] && [ "$variant" != "system" ]; then
eligible=false
fi
echo "eligible=$eligible" >> "$GITHUB_OUTPUT"
echo "Plan: variant=$variant binary=$binary slice=$slice → eligible=$eligible"
- name: Checkout
if: steps.pin.outputs.eligible == 'true'
uses: actions/checkout@v4
with:
ref: ${{ needs.plan.outputs.tag }}
fetch-depth: 0
- name: Install Nix
if: steps.pin.outputs.eligible == 'true'
uses: DeterminateSystems/nix-installer-action@main
with:
extra-conf: |
experimental-features = nix-command flakes
accept-flake-config = true
# Attic-backed binary cache hosted at cache.sergioribera.rs (`main`).
# The action wires the cache up as a substituter (so the build can pull
# nixpkgs + workspace artifacts that were already pushed) and uploads
# any new store paths created during the job in its post step.
- name: Attic cache
if: steps.pin.outputs.eligible == 'true'
uses: ryanccn/attic-action@v0
with:
endpoint: https://cache.sergioribera.rs
cache: main
token: ${{ secrets.ATTIC_TOKEN }}
- name: Build release slice
if: steps.pin.outputs.eligible == 'true'
run: |
# `system` variant for `sss` maps to the bare `release-sss`
# flake output; all other variants suffix the variant name.
# `sss_code` only ever uses the bare `release-sss_code` output.
attr="release-${{ needs.plan.outputs.binary }}"
if [ "${{ needs.plan.outputs.binary }}" = "sss" ] && [ "${{ matrix.variant }}" != "system" ]; then
attr="release-sss-${{ matrix.variant }}"
fi
# `--fallback` rebuilds from source when a substituter starts
# serving a NAR but the stream breaks mid-transfer (we hit
# HTTP/2 framing errors against attic intermittently). Without
# this the build aborts even though Nix could just re-derive
# the path locally.
nix build ".#$attr" -L --fallback --print-out-paths
- name: Stage artifacts
if: steps.pin.outputs.eligible == 'true'
run: |
mkdir -p dist
# `cp -RL` portable across GNU + BSD. nix store paths are 555 —
# `chmod -R u+w` makes the staged copy writable so later steps
# (upload-artifact, mv) don't hit EACCES on macOS.
cp -RL result/* dist/
chmod -R u+w dist/
# Per-slice + per-variant INSTALL.md/install.sh fragments —
# the publish job merges them into a single unified release.
tag="${{ matrix.slice.slice }}-${{ matrix.variant }}"
mv dist/INSTALL.md "dist/INSTALL-${tag}.md" 2>/dev/null || true
mv dist/install.sh "dist/install-${tag}.sh" 2>/dev/null || true
mv dist/install.ps1 "dist/install-${tag}.ps1" 2>/dev/null || true
mv dist/SHA256SUMS "dist/SHA256SUMS-${tag}" 2>/dev/null || true
ls -la dist
- name: Upload slice
if: steps.pin.outputs.eligible == 'true'
uses: actions/upload-artifact@v4
with:
name: bundles-${{ matrix.slice.slice }}-${{ matrix.variant }}
path: dist/
if-no-files-found: error
retention-days: 1
publish:
name: Publish GitHub Release
needs: [plan, bundles]
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
has_aur: ${{ steps.aur_detect.outputs.has_aur }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ needs.plan.outputs.tag }}
fetch-depth: 0
# Download each (slice, variant) artifact into its own subdir so the
# variant-specific `aur/` layouts don't clobber each other (their
# filenames inside `aur/` are the same `PKGBUILD` / `.SRCINFO`,
# only the content differs). Then flatten the non-AUR pieces into
# `dist/` — variant disambiguation lives in the bundle filename
# (`sss-*` vs `sss-no-ocr-*`) so no collisions there.
- name: Download all bundles
uses: actions/download-artifact@v4
with:
pattern: bundles-*
path: bundles_raw
- name: Flatten non-AUR artifacts into dist/
run: |
mkdir -p dist aur_system aur_nvidia aur_rocm aur_noocr
shopt -s nullglob
for sub in bundles_raw/*/; do
slice_dir="${sub%/}"
name=$(basename "$slice_dir")
variant=system
case "$name" in
*-system) variant=system ;;
*-nvidia) variant=nvidia ;;
*-rocm) variant=rocm ;;
*-noocr) variant=noocr ;;
esac
# Pull the AUR source layout aside per variant so the four
# PKGBUILD/.SRCINFO sets don't clobber each other (filenames
# are identical inside `aur/`, only content differs).
if [ -d "$slice_dir/aur" ]; then
cp -r "$slice_dir/aur/." "aur_${variant}/" || true
rm -rf "$slice_dir/aur"
fi
# Everything else flattens straight in. Brew formulas (.rb)
# stay per-slice because the homebrew job needs to merge
# them by arch — we copy them into dist/ keyed by source
# subdir name.
cp -r "$slice_dir/." "dist/"
done
ls -la dist | head -50
- name: Generate unified SHA256SUMS
working-directory: dist
run: |
rm -f SHA256SUMS-*
find . -maxdepth 1 -type f \
! -name 'SHA256SUMS*' \
! -name 'INSTALL-*.md' \
! -name 'install-*.sh' \
! -name 'install-*.ps1' \
-printf '%P\n' \
| LC_ALL=C sort \
| xargs sha256sum -- > SHA256SUMS
- name: Assemble unified INSTALL.md + install.sh
working-directory: dist
run: |
# Each per-slice bundle ships its own INSTALL-<slice>.md /
# install-<slice>.sh whose only slice-specific bit is the
# arch/format table (resp. file_for() cases + list_entries
# body). The static prose, env vars, help text etc. are
# identical across slices. So: take the first slice as the
# template, then splice in the union of per-slice table rows
# / cases / list lines from every slice. One unified
# INSTALL.md, one unified install.sh — both arch-agnostic.
shopt -s nullglob
md_slices=(INSTALL-*.md)
sh_slices=(install-*.sh)
if [ "${#md_slices[@]}" -gt 0 ]; then
template="${md_slices[0]}"
# Pull the union of "| arch | os | format | file |" rows from
# every slice's "Bundles in this release" table. Dedupe.
{
for f in "${md_slices[@]}"; do
awk '/^\| Arch \| OS \| Format \| File \|$/{t=1; next}
t && /^\|-/{next}
t && /^\|/{print; next}
t{exit}' "$f"
done
} | LC_ALL=C sort -u > .bundle-rows
# Splice the union back into the template's table location.
awk -v rows=.bundle-rows '
/^\| Arch \| OS \| Format \| File \|$/{
print
print "|------|----|--------|------|"
while ((getline line < rows) > 0) print line
close(rows)
in_table=1
next
}
in_table && /^\|-/{next}
in_table && /^\|/{next}
in_table{in_table=0}
{print}
' "$template" > INSTALL.md
rm -f .bundle-rows
fi
if [ "${#sh_slices[@]}" -gt 0 ]; then
template_sh="${sh_slices[0]}"
# Union of file_for() case bodies (between `case "$1" in` and
# `*) return 1 ;;`). Lines look like
# "x86_64:Linux:archlinux") echo "..."; return ;;
{
for f in "${sh_slices[@]}"; do
awk '/^file_for\(\) \{/{f=1; next}
f && /case "\$1" in/{c=1; next}
c && /\*\) return 1/{exit}
c{print}' "$f"
done
} | LC_ALL=C sort -u > .sh-cases
# Union of list_entries() body — the human-readable
# "Configured bundles" listing inside the heredoc.
{
for f in "${sh_slices[@]}"; do
awk '/^list_entries\(\) \{/{f=1; next}
f && /Configured bundles:/{c=1; next}
c && /^EOF$/{exit}
c{print}' "$f"
done
} | LC_ALL=C sort -u > .sh-list
awk -v cases=.sh-cases -v listing=.sh-list '
/^file_for\(\) \{/{print; in_ff=1; next}
in_ff && /case "\$1" in/{
print
while ((getline line < cases) > 0) print line
close(cases)
in_cases=1
next
}
in_cases && /\*\) return 1/{in_cases=0; in_ff=0}
in_cases{next}
/^list_entries\(\) \{/{print; in_le=1; next}
in_le && /Configured bundles:/{
print
while ((getline line < listing) > 0) print line
close(listing)
in_list=1
next
}
in_list && /^EOF$/{in_list=0; in_le=0}
in_list{next}
{print}
' "$template_sh" > install.sh
chmod +x install.sh
rm -f .sh-cases .sh-list
fi
rm -f INSTALL-*.md install-*.sh install-*.ps1
echo "--- INSTALL.md preview ---"
head -60 INSTALL.md
echo "--- install.sh file_for() preview ---"
awk '/^file_for/,/^\}/' install.sh
- name: Pack AUR layouts for downstream workflow
id: aur_detect
run: |
# The archlinux format emits the AUR layout in `aur/` next to
# the .pkg.tar.zst. The AUR push workflow expects one tarball
# per AUR package. For `sss` we ship four: `sss-bin`,
# `sss-nvidia-bin`, `sss-rocm-bin`, `sss-noocr-bin`.
# `sss_code` only ships one: `sss_code-bin`.
ver="${{ needs.plan.outputs.version }}"
binary="${{ needs.plan.outputs.binary }}"
has_any=false
pack() {
local dir=$1 pkg=$2
if [ -d "$dir" ] && [ -n "$(ls -A "$dir")" ]; then
# The archlinux format emits the AUR source tarball
# (`<pkg>-<ver>-<arch>.tar.gz`) inside `aur/` alongside
# PKGBUILD. PKGBUILD's `source=()` points to it as a
# release asset URL, so we lift it into `dist/` to be
# uploaded directly — otherwise makepkg/updpkgsums 404s.
for src_tar in "$dir"/${pkg}-*.tar.gz; do
[ -f "$src_tar" ] || continue
cp "$src_tar" "dist/$(basename "$src_tar")"
done
tar czf "dist/${pkg}-${ver}-aur.tar.gz" -C "$dir" .
has_any=true
fi
}
if [ "$binary" = "sss" ]; then
pack aur_system sss-bin
pack aur_nvidia sss-nvidia-bin
pack aur_rocm sss-rocm-bin
pack aur_noocr sss-noocr-bin
elif [ "$binary" = "sss_code" ]; then
pack aur_system sss_code-bin
fi
echo "has_aur=$has_any" >> "$GITHUB_OUTPUT"
- name: Generate changelog with git-cliff
uses: orhun/git-cliff-action@v4
id: cliff
with:
config: cliff.toml
# Scope the changelog to the binary being released:
# * `--tag-pattern` restricts the "previous tag" walk to the
# same release stream so a sss_cli release doesn't reach
# back to the most recent sss_code tag (or vice versa) and
# drag every workspace commit since then into the body.
# * `--include-path` keeps only commits touching the binary's
# own crate directory. Workspace-wide changes (shared libs,
# CI infra, docs) intentionally don't land here — they show
# up in whichever binary they actually affected.
# * `--tag` is explicit instead of `--current`: cargo-release
# can land cross-tag commits at the same SHA, so trusting
# the implicit HEAD lookup mis-attributes the release.
args: >
--latest
--tag-pattern '${{ needs.plan.outputs.tag_pattern }}'
${{ needs.plan.outputs.cliff_paths }}
--strip header
env:
OUTPUT: CHANGELOG.md
- name: Assemble release body
run: |
{
cat CHANGELOG.md 2>/dev/null || echo "_(no changelog generated)_"
echo
cat dist/INSTALL.md
} > RELEASE_BODY.md
echo "--- release body preview ---"
head -120 RELEASE_BODY.md
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.plan.outputs.tag }}
name: ${{ needs.plan.outputs.release_name }}
draft: false
prerelease: ${{ contains(needs.plan.outputs.tag, '-') }}
body_path: RELEASE_BODY.md
# The globs below cover every format nix-bundle-app may emit,
# but most releases only produce a subset (sss_code skips deb
# variants on some slices, darwin doesn't produce .deb / .rpm,
# etc). `fail_on_unmatched_files: false` lets the publish
# succeed against whichever shape this release happened to
# produce; the SHA256SUMS still pins the full asset list.
fail_on_unmatched_files: false
# Exclude the `aur/` subdir from the release upload — it's the
# source layout for the AUR repo, not a user-installable artifact.
# AppImage intentionally absent: dropped in release.nix because
# the binary expects a distro-installed libonnxruntime which
# breaks AppImage's portability promise. Listing the glob with
# `fail_on_unmatched_files: true` would abort the publish job.
files: |
dist/*.deb
dist/*.rpm
dist/*.pkg.tar.zst
dist/*.tar.gz
dist/*.tar.zst
dist/*.dmg
dist/*.pkg
dist/*.rb
dist/*-aur.tar.gz
dist/INSTALL.md
dist/SHA256SUMS
dist/install.sh
# Pushes Homebrew formula files to SergioRibera/homebrew-tap. Triggered
# by the AUR push workflow indirectly (both react to a successful
# Release run via workflow_run). AUR runs in aur.yml, brew runs here so
# the secrets stay isolated per ecosystem.
homebrew:
name: Publish to Homebrew tap
needs: [plan, publish]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# Keep per-slice subdirs — each runner's .rb has the SAME filename
# (`sss.rb` / `sss_code.rb`) but different content per arch, so
# `merge-multiple: true` would silently lose one arch.
- name: Download brew formulas
uses: actions/download-artifact@v4
with:
pattern: bundles-*
path: dist
# nix-bundle-app emits one single-arch formula per (binary, target),
# and its `url` field is the bare `meta.downloadUrl` (a directory).
# We merge per-arch into a universal `on_macos { on_arm / on_intel }`
# formula AND rewrite the url to the actual tarball asset for that
# arch. Output filename + class are remapped per binary so the tap
# keeps the legacy `sss_cli.rb` / `sss_code.rb` names (cli's crate
# is `sss_cli` but its binary is `sss` — Homebrew users typed
# `brew install sergioribera/tap/sss_cli` historically).
- name: Merge per-arch formulas into universal
id: merge
run: |
python3 - <<'PY'
import collections, glob, os, pathlib, re
# internal-name (from meta.name) -> (formula filename stem,
# ruby class, installed binary). The brew test stanza needs the
# ACTUAL binary that lands in $bin, not the formula stem.
# Only the `system` and `noocr` variants ship on macOS — nvidia
# and rocm are Linux-only and never produce a brew formula.
rename = {
"sss": ("sss_cli", "SssCli", "sss"),
"sss-noocr": ("sss_cli_noocr", "SssCliNoocr", "sss"),
"sss_code": ("sss_code", "SssCode", "sss_code"),
}
slices = collections.defaultdict(dict) # name -> {arch: (url, sha)}
fields = {} # name -> shared metadata
# Slice dir name is `bundles-darwin-<arch>-<variant>`. We only
# care about <arch> for the merge axis — the formula stem
# already disambiguates the variant via `meta.name`.
for slice_dir in sorted(glob.glob("dist/bundles-darwin-*")):
base = pathlib.Path(slice_dir).name
arch = "arm" if "aarch64" in base else "intel"
os_label = "darwin"
for rb in glob.glob(f"{slice_dir}/*.rb"):
text = pathlib.Path(rb).read_text()
name = pathlib.Path(rb).stem
base_url = re.search(r'url\s+"([^"]+)"', text).group(1)
sha = re.search(r'sha256\s+"([^"]+)"', text).group(1)
version = re.search(r'version\s+"([^"]+)"', text).group(1)
# Construct the actual tarball asset URL.
asset_arch = "x86_64" if arch == "intel" else "aarch64"
tarball = f"{name}-{version}-{asset_arch}-{os_label}.tar.gz"
url = base_url.rstrip("/") + "/" + tarball
slices[name][arch] = (url, sha)
fields.setdefault(name, {
"desc": re.search(r'desc\s+"([^"]+)"', text).group(1),
"homepage": re.search(r'homepage\s+"([^"]+)"', text).group(1),
"version": version,
"license": re.search(r'license\s+"([^"]+)"', text).group(1),
})
if not slices:
print("::notice::no darwin .rb formulas to publish")
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
fh.write("skip=true\n")
raise SystemExit(0)
os.makedirs("formulas", exist_ok=True)
for name, f in fields.items():
file_stem, klass, binary = rename.get(
name, (name, name[:1].upper() + name[1:], name)
)
arches = slices[name]
parts = [
f"class {klass} < Formula",
f' desc "{f["desc"]}"',
f' homepage "{f["homepage"]}"',
f' version "{f["version"]}"',
f' license "{f["license"]}"',
"",
" on_macos do",
]
for label, key in (("on_arm", "arm"), ("on_intel", "intel")):
if key in arches:
url, sha = arches[key]
parts += [
f" {label} do",
f' url "{url}"',
f' sha256 "{sha}"',
" end",
]
parts += [
" end",
"",
" def install",
' bin.install Dir["bin/*"]',
' lib.install Dir["lib/*"] if Dir.exist?("lib")',
' share.install Dir["share/*"] if Dir.exist?("share")',
" end",
"",
" test do",
f' system "#{{bin}}/{binary}", "--version"',
" end",
"end",
"",
]
pathlib.Path(f"formulas/{file_stem}.rb").write_text("\n".join(parts))
print(f"wrote formulas/{file_stem}.rb ({', '.join(arches)})")
PY
ls -la formulas || true
- name: Checkout homebrew-tap
if: steps.merge.outputs.skip != 'true'
uses: actions/checkout@v4
with:
repository: SergioRibera/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap
- name: Commit + push formulas
if: steps.merge.outputs.skip != 'true'
working-directory: tap
env:
BINARY: ${{ needs.plan.outputs.binary }}
VERSION: ${{ needs.plan.outputs.version }}
run: |
mkdir -p Formula
cp -f ../formulas/*.rb Formula/
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/*.rb
if git diff --cached --quiet; then
echo "no formula changes"
exit 0
fi
git commit -m "$BINARY $VERSION"
git push