Skip to content
This repository was archived by the owner on Oct 9, 2026. It is now read-only.
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
164 changes: 128 additions & 36 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,144 @@ on:
push:
tags:
- "[0-9]+.*"
pull_request:
paths:
- .github/workflows/release.yml
- scripts/release.py
- xtask/**
- tui/**
workflow_dispatch:

jobs:
create-release:
permissions:
contents: write
runs-on: ubuntu-22.04
steps:
# v4.2.2
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: taiki-e/create-gh-release-action@eba8ea96c86cca8a37f1b56e94b4d13301fba651 # v1
with:
# Publish immediately: Review's build downloads the assets by tag.
draft: false
env:
# (required)
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read

upload-assets:
needs: create-release
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-latest
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-22.04
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.10
- uses: dtolnay/rust-toolchain@stable
- name: Build and package
env:
TARGET: ${{ matrix.target }}
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
run: |
version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "diffr-cli") | .version')
if [ -n "$RELEASE_TAG" ] && [ "$RELEASE_TAG" != "$version" ]; then
echo "Tag $RELEASE_TAG does not match Cargo version $version" >&2
exit 1
fi
cargo xtask install --root "$RUNNER_TEMP/install"
python3 scripts/release.py pack --version "$version" --target "$TARGET" --install "$RUNNER_TEMP/install" --output dist
- name: Verify extracted archive without Bun or checkout assets
run: |
mkdir -p "$RUNNER_TEMP/extracted/bin"
tar -xzf dist/*.tar.gz -C "$RUNNER_TEMP/extracted/bin"
python3 xtask/tests/smoke_install.py "$RUNNER_TEMP/extracted"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ matrix.target }}
path: dist/*.tar.gz
if-no-files-found: error

prepare:
needs: build
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: dist
merge-multiple: true
- name: Generate checksums and Homebrew formula
run: |
version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
python3 scripts/release.py formula --version "$version" --output dist
(cd dist && sha256sum --check SHA256SUMS)
ruby -c dist/diffr.rb
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release
path: dist/*
if-no-files-found: error

homebrew:
needs: prepare
strategy:
fail-fast: false
matrix:
os: [macos-14, ubuntu-22.04]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
env:
HOMEBREW_NO_AUTO_UPDATE: '1'
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release
path: dist
- name: Install and test the formula against staged archives
shell: bash
run: |
if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
fi
tap="$(brew --repository)/Library/Taps/devdotfast/homebrew-tap"
mkdir -p "$tap/Formula"
python3 - "$tap/Formula/diffr.rb" <<'PYTHON'
from pathlib import Path
import re
import sys
formula = Path("dist/diffr.rb").read_text()
formula = re.sub(r'https://github.com/devdotfast/diffr/releases/download/[^/]+/', Path("dist").resolve().as_uri() + "/", formula)
Path(sys.argv[1]).write_text(formula)
PYTHON
brew install devdotfast/tap/diffr
brew test devdotfast/tap/diffr
python3 - "$tap/Formula/diffr.rb" <<'PYTHON'
from pathlib import Path
import sys
path = Path(sys.argv[1])
path.write_text(path.read_text().replace(' license ', ' revision 1\n license ', 1))
PYTHON
brew upgrade devdotfast/tap/diffr
brew test devdotfast/tap/diffr
python3 xtask/tests/smoke_install.py "$(brew --prefix devdotfast/tap/diffr)"
brew uninstall devdotfast/tap/diffr

publish:
if: github.event_name == 'push' && github.ref_type == 'tag'
needs: homebrew
permissions:
contents: write
runs-on: ubuntu-22.04
steps:
# v4.2.2
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: taiki-e/upload-rust-binary-action@f0d45ae91ee7b8ee928de7a9d04d893a08bcbec6 # v1.30.2
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
bin: diffr
archive: $bin-$tag-$target
# Ensure we respect Cargo.lock in release builds, so the
# released versions match those tested in CI.
locked: true
# (optional) On which platform to distribute the `.tar.gz` file.
tar: unix
# (optional) On which platform to distribute the `.zip` file.
zip: windows
# (optional) Target triple, default is host triple.
target: ${{ matrix.target }}
name: release
path: dist
- name: Publish complete release
env:
# (required)
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
gh release create "$RELEASE_TAG" dist/* --verify-tag --draft --title "$RELEASE_TAG" --generate-notes
gh release edit "$RELEASE_TAG" --draft=false --latest
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description = "Structural diffs with a streaming API and interactive terminal fr
repository = "https://github.com/devdotfast/diffr"
homepage = "https://github.com/devdotfast/diffr"
license = "MIT"
version = "0.1.1"
version = "0.1.2"
authors = ["dev.fast"]
keywords = ["diff", "syntax"]
categories = ["development-tools", "command-line-utilities", "parser-implementations"]
Expand Down
29 changes: 29 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Releases and Homebrew

The release workflow builds `diffr` and the standalone `diffr-tui` on Apple Silicon
macOS and x86-64 Linux. Bun 1.3.10 is required only when building. Each archive
contains both executables at its root, plus license notices. Existing consumers
can continue extracting only `diffr` using the original asset naming convention.

Before tagging, update the root package version in Cargo.toml and its Cargo.lock
entry. Keep plugin dependency versions unchanged unless those crates also change.
The tag must match the CLI version exactly (`X.Y.Z`). Do not replace old assets:
consumers pin their checksums.

Release packaging runs on relevant pull requests and can be run manually without
publishing. Each platform checks an extracted archive in a fresh directory with an
empty PATH, exercising settings and interactive diff rendering without Bun or the
checkout. The workflow generates `SHA256SUMS` and `diffr.rb` from both archives.
Only after validation does a tag build upload a draft release and publish it.

If upload fails, inspect and remove the incomplete draft before rerunning the
publish job. Never delete or overwrite an already public release to retry it.

The public `devdotfast/homebrew-tap` repository checks the latest release hourly
and installs/tests its formula before committing an update. Its workflow also has
a manual trigger. This uses the tap's own GitHub token, with no cross-repository
credential. Releases predating the `diffr.rb` asset are ignored.

Install with `brew install devdotfast/tap/diffr`; update with `brew update` followed
by `brew upgrade devdotfast/tap/diffr`. To install manually, verify the archive with
`SHA256SUMS`, extract it, and copy both executables into the same directory on PATH.
88 changes: 88 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Build release archives and a Homebrew formula from their exact bytes."""
import argparse
import hashlib
from pathlib import Path
import re
import subprocess
import tarfile

TARGETS = ("aarch64-apple-darwin", "x86_64-unknown-linux-gnu")
ROOT = Path(__file__).resolve().parent.parent


def archive_name(version, target):
return f"diffr-{version}-{target}.tar.gz"


def pack(version, target, install, output):
actual = subprocess.check_output([install / "bin/diffr", "--version"], text=True).strip()
if actual != f"diffr {version}":
raise ValueError(f"Release version mismatch: {actual}")
with tarfile.open(output / archive_name(version, target), "w:gz") as archive:
for name in ("diffr", "diffr-tui"):
archive.add(install / "bin" / name, arcname=name)
for name in ("LICENSE", "NOTICE", "tui/LICENSE", "tui/themes/LICENSE"):
archive.add(ROOT / name, arcname=name)


def formula(version, output):
checksums = {}
for target in TARGETS:
name = archive_name(version, target)
checksums[target] = hashlib.sha256((output / name).read_bytes()).hexdigest()
(output / "SHA256SUMS").write_text("".join(
f"{checksums[target]} {archive_name(version, target)}\n" for target in TARGETS
))
url = f"https://github.com/devdotfast/diffr/releases/download/{version}/diffr-{version}"
(output / "diffr.rb").write_text(f'''class Diffr < Formula
desc "Structural diffs with an interactive terminal frontend"
homepage "https://github.com/devdotfast/diffr"
version "{version}"
license all_of: ["MIT", "MPL-2.0"]

on_macos do
depends_on arch: :arm64
url "{url}-aarch64-apple-darwin.tar.gz"
sha256 "{checksums[TARGETS[0]]}"
end

on_linux do
depends_on arch: :x86_64
url "{url}-x86_64-unknown-linux-gnu.tar.gz"
sha256 "{checksums[TARGETS[1]]}"
end

def install
bin.install "diffr", "diffr-tui"
doc.install "NOTICE"
(pkgshare/"licenses").install "LICENSE"
(pkgshare/"licenses/tui").install "tui/LICENSE"
(pkgshare/"licenses/themes").install "tui/themes/LICENSE"
end

test do
assert_match version.to_s, shell_output("#{{bin}}/diffr --version")
assert_match "theme", shell_output("#{{bin}}/diffr config schema")
assert_predicate bin/"diffr-tui", :executable?
end
end
''')


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("pack", "formula"))
parser.add_argument("--version", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--target", choices=TARGETS)
parser.add_argument("--install", type=Path)
args = parser.parse_args()
if not re.fullmatch(r"\d+\.\d+\.\d+", args.version):
parser.error("version must be a stable X.Y.Z release")
args.output.mkdir(parents=True, exist_ok=True)
if args.command == "pack":
if not args.target or not args.install:
parser.error("pack requires --target and --install")
pack(args.version, args.target, args.install.resolve(), args.output)
else:
formula(args.version, args.output)
11 changes: 10 additions & 1 deletion xtask/tests/smoke_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import shutil
import signal
import struct
import subprocess
import sys
import tempfile
import termios
Expand Down Expand Up @@ -74,4 +75,12 @@ def check(root, args, expected):
(root / "before.txt").write_text("old packaging\n")
(root / "after.txt").write_text("standalone packaging\n")
check(root, ["config"], b"Collapse unchanged lines")
check(root, ["--no-index", "--", "before.txt", "after.txt"], b"standalone")
for theme in ("default-dark", "default-light", "gruvbox", "solarized_light"):
subprocess.run(
[root / "diffr", "config", "set", "theme.name", theme],
env={"PATH": "", "HOME": str(root), "XDG_CONFIG_HOME": str(root / "config")},
check=True,
capture_output=True,
)
check(root, ["--no-index", "--", "before.txt", "after.txt"], b"standalone")
print(f"PASS bundled theme: {theme}")
Loading