diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f9f60c53..9400579a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 8519f6da8..82bea3458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -677,7 +677,7 @@ checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] name = "diffr-cli" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 9a180a8f0..fb8ecd51a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 000000000..b3a4e8610 --- /dev/null +++ b/docs/releases.md @@ -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. diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 000000000..65f37dac1 --- /dev/null +++ b/scripts/release.py @@ -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) diff --git a/xtask/tests/smoke_install.py b/xtask/tests/smoke_install.py index c9205c1fb..2b1a19744 100644 --- a/xtask/tests/smoke_install.py +++ b/xtask/tests/smoke_install.py @@ -12,6 +12,7 @@ import shutil import signal import struct +import subprocess import sys import tempfile import termios @@ -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}")