Skip to content
This repository was archived by the owner on Oct 9, 2026. It is now read-only.

Commit e5243c8

Browse files
authored
Ship the standalone TUI in releases and add Homebrew packaging (#29)
* Package standalone TUI releases and generate Homebrew formula AI-assisted implementation and validation with Codex. Agent-Session: 01a0cc1f-6e05-7822-b066-68d47380e331 * Verify bundled themes and Homebrew upgrades AI-assisted implementation with Codex. Agent-Session: 01a0cc1f-6e05-7822-b066-68d47380e331
1 parent f25bd7e commit e5243c8

6 files changed

Lines changed: 257 additions & 39 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 128 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,52 +4,144 @@ on:
44
push:
55
tags:
66
- "[0-9]+.*"
7+
pull_request:
8+
paths:
9+
- .github/workflows/release.yml
10+
- scripts/release.py
11+
- xtask/**
12+
- tui/**
13+
workflow_dispatch:
714

8-
jobs:
9-
create-release:
10-
permissions:
11-
contents: write
12-
runs-on: ubuntu-22.04
13-
steps:
14-
# v4.2.2
15-
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
16-
- uses: taiki-e/create-gh-release-action@eba8ea96c86cca8a37f1b56e94b4d13301fba651 # v1
17-
with:
18-
# Publish immediately: Review's build downloads the assets by tag.
19-
draft: false
20-
env:
21-
# (required)
22-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
15+
permissions:
16+
contents: read
2317

24-
upload-assets:
25-
needs: create-release
26-
permissions:
27-
contents: write
18+
concurrency:
19+
group: release-${{ github.ref }}
20+
cancel-in-progress: false
21+
22+
jobs:
23+
build:
2824
strategy:
2925
fail-fast: false
3026
matrix:
3127
include:
3228
- target: aarch64-apple-darwin
33-
os: macos-latest
29+
os: macos-14
3430
- target: x86_64-unknown-linux-gnu
3531
os: ubuntu-22.04
3632
runs-on: ${{ matrix.os }}
33+
timeout-minutes: 45
34+
steps:
35+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
36+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
37+
with:
38+
bun-version: 1.3.10
39+
- uses: dtolnay/rust-toolchain@stable
40+
- name: Build and package
41+
env:
42+
TARGET: ${{ matrix.target }}
43+
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
44+
run: |
45+
version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "diffr-cli") | .version')
46+
if [ -n "$RELEASE_TAG" ] && [ "$RELEASE_TAG" != "$version" ]; then
47+
echo "Tag $RELEASE_TAG does not match Cargo version $version" >&2
48+
exit 1
49+
fi
50+
cargo xtask install --root "$RUNNER_TEMP/install"
51+
python3 scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/install" --output dist
52+
- name: Verify extracted archive without Bun or checkout assets
53+
run: |
54+
mkdir -p "$RUNNER_TEMP/extracted/bin"
55+
tar -xzf dist/*.tar.gz -C "$RUNNER_TEMP/extracted/bin"
56+
python3 xtask/tests/smoke_install.py "$RUNNER_TEMP/extracted"
57+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
58+
with:
59+
name: ${{ matrix.target }}
60+
path: dist/*.tar.gz
61+
if-no-files-found: error
62+
63+
prepare:
64+
needs: build
65+
runs-on: ubuntu-22.04
66+
steps:
67+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
68+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
69+
with:
70+
path: dist
71+
merge-multiple: true
72+
- name: Generate checksums and Homebrew formula
73+
run: |
74+
version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
75+
python3 scripts/release.py formula --version "$version" --output dist
76+
(cd dist && sha256sum --check SHA256SUMS)
77+
ruby -c dist/diffr.rb
78+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
79+
with:
80+
name: release
81+
path: dist/*
82+
if-no-files-found: error
83+
84+
homebrew:
85+
needs: prepare
86+
strategy:
87+
fail-fast: false
88+
matrix:
89+
os: [macos-14, ubuntu-22.04]
90+
runs-on: ${{ matrix.os }}
91+
timeout-minutes: 15
92+
env:
93+
HOMEBREW_NO_AUTO_UPDATE: '1'
94+
steps:
95+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
96+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
97+
with:
98+
name: release
99+
path: dist
100+
- name: Install and test the formula against staged archives
101+
shell: bash
102+
run: |
103+
if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then
104+
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
105+
fi
106+
tap="$(brew --repository)/Library/Taps/devdotfast/homebrew-tap"
107+
mkdir -p "$tap/Formula"
108+
python3 - "$tap/Formula/diffr.rb" <<'PYTHON'
109+
from pathlib import Path
110+
import re
111+
import sys
112+
formula = Path("dist/diffr.rb").read_text()
113+
formula = re.sub(r'https://github.com/devdotfast/diffr/releases/download/[^/]+/', Path("dist").resolve().as_uri() + "/", formula)
114+
Path(sys.argv[1]).write_text(formula)
115+
PYTHON
116+
brew install devdotfast/tap/diffr
117+
brew test devdotfast/tap/diffr
118+
python3 - "$tap/Formula/diffr.rb" <<'PYTHON'
119+
from pathlib import Path
120+
import sys
121+
path = Path(sys.argv[1])
122+
path.write_text(path.read_text().replace(' license ', ' revision 1\n license ', 1))
123+
PYTHON
124+
brew upgrade devdotfast/tap/diffr
125+
brew test devdotfast/tap/diffr
126+
python3 xtask/tests/smoke_install.py "$(brew --prefix devdotfast/tap/diffr)"
127+
brew uninstall devdotfast/tap/diffr
128+
129+
publish:
130+
if: github.event_name == 'push' && github.ref_type == 'tag'
131+
needs: homebrew
132+
permissions:
133+
contents: write
134+
runs-on: ubuntu-22.04
37135
steps:
38-
# v4.2.2
39-
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
40-
- uses: taiki-e/upload-rust-binary-action@f0d45ae91ee7b8ee928de7a9d04d893a08bcbec6 # v1.30.2
136+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
41137
with:
42-
bin: diffr
43-
archive: $bin-$tag-$target
44-
# Ensure we respect Cargo.lock in release builds, so the
45-
# released versions match those tested in CI.
46-
locked: true
47-
# (optional) On which platform to distribute the `.tar.gz` file.
48-
tar: unix
49-
# (optional) On which platform to distribute the `.zip` file.
50-
zip: windows
51-
# (optional) Target triple, default is host triple.
52-
target: ${{ matrix.target }}
138+
name: release
139+
path: dist
140+
- name: Publish complete release
53141
env:
54-
# (required)
55-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
142+
GH_TOKEN: ${{ github.token }}
143+
GH_REPO: ${{ github.repository }}
144+
RELEASE_TAG: ${{ github.ref_name }}
145+
run: |
146+
gh release create "$RELEASE_TAG" dist/* --verify-tag --draft --title "$RELEASE_TAG" --generate-notes
147+
gh release edit "$RELEASE_TAG" --draft=false --latest

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description = "Structural diffs with a streaming API and interactive terminal fr
44
repository = "https://github.com/devdotfast/diffr"
55
homepage = "https://github.com/devdotfast/diffr"
66
license = "MIT"
7-
version = "0.1.1"
7+
version = "0.1.2"
88
authors = ["dev.fast"]
99
keywords = ["diff", "syntax"]
1010
categories = ["development-tools", "command-line-utilities", "parser-implementations"]

‎docs/releases.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Releases and Homebrew
2+
3+
The release workflow builds `diffr` and the standalone `diffr-tui` on Apple Silicon
4+
macOS and x86-64 Linux. Bun 1.3.10 is required only when building. Each archive
5+
contains both executables at its root, plus license notices. Existing consumers
6+
can continue extracting only `diffr` using the original asset naming convention.
7+
8+
Before tagging, update the root package version in Cargo.toml and its Cargo.lock
9+
entry. Keep plugin dependency versions unchanged unless those crates also change.
10+
The tag must match the CLI version exactly (`X.Y.Z`). Do not replace old assets:
11+
consumers pin their checksums.
12+
13+
Release packaging runs on relevant pull requests and can be run manually without
14+
publishing. Each platform checks an extracted archive in a fresh directory with an
15+
empty PATH, exercising settings and interactive diff rendering without Bun or the
16+
checkout. The workflow generates `SHA256SUMS` and `diffr.rb` from both archives.
17+
Only after validation does a tag build upload a draft release and publish it.
18+
19+
If upload fails, inspect and remove the incomplete draft before rerunning the
20+
publish job. Never delete or overwrite an already public release to retry it.
21+
22+
The public `devdotfast/homebrew-tap` repository checks the latest release hourly
23+
and installs/tests its formula before committing an update. Its workflow also has
24+
a manual trigger. This uses the tap's own GitHub token, with no cross-repository
25+
credential. Releases predating the `diffr.rb` asset are ignored.
26+
27+
Install with `brew install devdotfast/tap/diffr`; update with `brew update` followed
28+
by `brew upgrade devdotfast/tap/diffr`. To install manually, verify the archive with
29+
`SHA256SUMS`, extract it, and copy both executables into the same directory on PATH.

‎scripts/release.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Build release archives and a Homebrew formula from their exact bytes."""
2+
import argparse
3+
import hashlib
4+
from pathlib import Path
5+
import re
6+
import subprocess
7+
import tarfile
8+
9+
TARGETS = ("aarch64-apple-darwin", "x86_64-unknown-linux-gnu")
10+
ROOT = Path(__file__).resolve().parent.parent
11+
12+
13+
def archive_name(version, target):
14+
return f"diffr-{version}-{target}.tar.gz"
15+
16+
17+
def pack(version, target, install, output):
18+
actual = subprocess.check_output([install / "bin/diffr", "--version"], text=True).strip()
19+
if actual != f"diffr {version}":
20+
raise ValueError(f"Release version mismatch: {actual}")
21+
with tarfile.open(output / archive_name(version, target), "w:gz") as archive:
22+
for name in ("diffr", "diffr-tui"):
23+
archive.add(install / "bin" / name, arcname=name)
24+
for name in ("LICENSE", "NOTICE", "tui/LICENSE", "tui/themes/LICENSE"):
25+
archive.add(ROOT / name, arcname=name)
26+
27+
28+
def formula(version, output):
29+
checksums = {}
30+
for target in TARGETS:
31+
name = archive_name(version, target)
32+
checksums[target] = hashlib.sha256((output / name).read_bytes()).hexdigest()
33+
(output / "SHA256SUMS").write_text("".join(
34+
f"{checksums[target]} {archive_name(version, target)}\n" for target in TARGETS
35+
))
36+
url = f"https://github.com/devdotfast/diffr/releases/download/{version}/diffr-{version}"
37+
(output / "diffr.rb").write_text(f'''class Diffr < Formula
38+
desc "Structural diffs with an interactive terminal frontend"
39+
homepage "https://github.com/devdotfast/diffr"
40+
version "{version}"
41+
license all_of: ["MIT", "MPL-2.0"]
42+
43+
on_macos do
44+
depends_on arch: :arm64
45+
url "{url}-aarch64-apple-darwin.tar.gz"
46+
sha256 "{checksums[TARGETS[0]]}"
47+
end
48+
49+
on_linux do
50+
depends_on arch: :x86_64
51+
url "{url}-x86_64-unknown-linux-gnu.tar.gz"
52+
sha256 "{checksums[TARGETS[1]]}"
53+
end
54+
55+
def install
56+
bin.install "diffr", "diffr-tui"
57+
doc.install "NOTICE"
58+
(pkgshare/"licenses").install "LICENSE"
59+
(pkgshare/"licenses/tui").install "tui/LICENSE"
60+
(pkgshare/"licenses/themes").install "tui/themes/LICENSE"
61+
end
62+
63+
test do
64+
assert_match version.to_s, shell_output("#{{bin}}/diffr --version")
65+
assert_match "theme", shell_output("#{{bin}}/diffr config schema")
66+
assert_predicate bin/"diffr-tui", :executable?
67+
end
68+
end
69+
''')
70+
71+
72+
if __name__ == "__main__":
73+
parser = argparse.ArgumentParser(description=__doc__)
74+
parser.add_argument("command", choices=("pack", "formula"))
75+
parser.add_argument("--version", required=True)
76+
parser.add_argument("--output", type=Path, required=True)
77+
parser.add_argument("--target", choices=TARGETS)
78+
parser.add_argument("--install", type=Path)
79+
args = parser.parse_args()
80+
if not re.fullmatch(r"\d+\.\d+\.\d+", args.version):
81+
parser.error("version must be a stable X.Y.Z release")
82+
args.output.mkdir(parents=True, exist_ok=True)
83+
if args.command == "pack":
84+
if not args.target or not args.install:
85+
parser.error("pack requires --target and --install")
86+
pack(args.version, args.target, args.install.resolve(), args.output)
87+
else:
88+
formula(args.version, args.output)

‎xtask/tests/smoke_install.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import shutil
1313
import signal
1414
import struct
15+
import subprocess
1516
import sys
1617
import tempfile
1718
import termios
@@ -74,4 +75,12 @@ def check(root, args, expected):
7475
(root / "before.txt").write_text("old packaging\n")
7576
(root / "after.txt").write_text("standalone packaging\n")
7677
check(root, ["config"], b"Collapse unchanged lines")
77-
check(root, ["--no-index", "--", "before.txt", "after.txt"], b"standalone")
78+
for theme in ("default-dark", "default-light", "gruvbox", "solarized_light"):
79+
subprocess.run(
80+
[root / "diffr", "config", "set", "theme.name", theme],
81+
env={"PATH": "", "HOME": str(root), "XDG_CONFIG_HOME": str(root / "config")},
82+
check=True,
83+
capture_output=True,
84+
)
85+
check(root, ["--no-index", "--", "before.txt", "after.txt"], b"standalone")
86+
print(f"PASS bundled theme: {theme}")

0 commit comments

Comments
 (0)