diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbd86c3..163089c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,8 +12,27 @@ env: CARGO_TERM_COLOR: always jobs: + verify: + name: Verify version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Cheap gate ahead of the build matrix: a tag that disagrees with + # Cargo.toml ships a binary whose --version lies about the release. + - name: Tag must match Cargo.toml version + run: | + tag="${GITHUB_REF_NAME#v}" + crate="$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)" + if [ "$tag" != "$crate" ]; then + echo "::error::Tag v$tag does not match Cargo.toml version $crate. Bump Cargo.toml and Cargo.lock, then re-tag." + exit 1 + fi + echo "Tag v$tag matches Cargo.toml version $crate" + build: name: Build ${{ matrix.target }} + needs: verify runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -93,3 +112,40 @@ jobs: with: generate_release_notes: true files: artifacts/* + + formula: + name: Update Homebrew formula + needs: release + runs-on: ubuntu-latest + steps: + # The tag is a detached commit; the formula lives on main, so check that + # out instead and commit the update there. + - uses: actions/checkout@v4 + with: + ref: main + # Full history: this job rebases onto origin/main before pushing, + # which a shallow clone cannot do reliably. + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Update formula + run: ./scripts/update-formula.py "${GITHUB_REF_NAME#v}" artifacts + + - name: Commit and push + run: | + if git diff --quiet Formula/ghdash.rb; then + echo "Formula already up to date for ${GITHUB_REF_NAME}" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/ghdash.rb + git commit -m "chore(brew): update formula to ${GITHUB_REF_NAME}" + # main may have moved while the matrix was building. + git pull --rebase origin main + git push origin HEAD:main diff --git a/Cargo.lock b/Cargo.lock index 0083324..806a6c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,7 +718,7 @@ dependencies = [ [[package]] name = "ghdash" -version = "0.3.1" +version = "0.3.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 4c1370a..ca16a88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ghdash" -version = "0.3.1" +version = "0.3.3" edition = "2024" license = "Apache-2.0" diff --git a/scripts/update-formula.py b/scripts/update-formula.py new file mode 100755 index 0000000..560033c --- /dev/null +++ b/scripts/update-formula.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Point the Homebrew formula at a released version. + +Sets `version` and, for every `url` line in the formula, the `sha256` of the +matching release artifact. Run by the release workflow once the artifacts are +built; also runnable by hand: + + scripts/update-formula.py 0.3.3 artifacts/ + +Every artifact the formula references must be present in the artifact +directory — a missing one is an error, never a silently kept stale checksum. +""" + +import argparse +import hashlib +import pathlib +import re +import sys + +URL_RE = re.compile(r'^(\s*)url ".*/(?P[^/"]+)"\s*$') +# Matches a checksum line in any state we may find one: a real digest, a +# placeholder, or either of those commented out. All are replaced outright, so +# a stale checksum never survives next to a fresh one. +SHA_RE = re.compile(r'^\s*#?\s*sha256 "[^"]*"\s*(#.*)?$') +VERSION_RE = re.compile(r'^(\s*version )"[^"]*"(\s*)$') + + +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def update(formula: str, version: str, artifact_dir: pathlib.Path) -> tuple[str, int]: + lines = formula.splitlines(keepends=True) + out: list[str] = [] + updated = 0 + missing: list[str] = [] + saw_version = False + + i = 0 + while i < len(lines): + line = lines[i] + + m = VERSION_RE.match(line) + if m and not saw_version: + out.append(f'{m.group(1)}"{version}"{m.group(2)}') + saw_version = True + i += 1 + continue + + m = URL_RE.match(line) + if not m: + out.append(line) + i += 1 + continue + + # The formula interpolates #{version} into the URL, so resolve it the + # same way Homebrew would before looking the artifact up on disk. + asset = m.group("asset").replace("#{version}", version) + indent = m.group(1) + out.append(line) + i += 1 + + artifact = artifact_dir / asset + if not artifact.is_file(): + missing.append(asset) + # Drop any existing checksum rather than leave a stale one behind. + if i < len(lines) and SHA_RE.match(lines[i]): + i += 1 + continue + + digest = sha256(artifact) + replacement = f'{indent}sha256 "{digest}"\n' + if i < len(lines) and SHA_RE.match(lines[i]): + if lines[i] != replacement: + updated += 1 + i += 1 + else: + updated += 1 + out.append(replacement) + + if not saw_version: + sys.exit("error: no `version \"...\"` line found in the formula") + if missing: + sys.exit( + "error: artifacts referenced by the formula are missing from " + f"{artifact_dir}: {', '.join(missing)}" + ) + + return "".join(out), updated + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help='release version without the "v" prefix') + parser.add_argument("artifact_dir", type=pathlib.Path, help="directory holding the release artifacts") + parser.add_argument( + "--formula", + type=pathlib.Path, + default=pathlib.Path("Formula/ghdash.rb"), + help="formula to rewrite (default: Formula/ghdash.rb)", + ) + parser.add_argument( + "--check", + action="store_true", + help="exit non-zero if the formula would change, without writing it", + ) + args = parser.parse_args() + + if args.version.startswith("v"): + sys.exit(f'error: pass the version without the "v" prefix, got {args.version!r}') + if not args.artifact_dir.is_dir(): + sys.exit(f"error: artifact directory not found: {args.artifact_dir}") + + original = args.formula.read_text() + updated, changed_checksums = update(original, args.version, args.artifact_dir) + + if updated == original: + print(f"{args.formula} is already up to date for {args.version}") + return + + if args.check: + sys.exit(f"error: {args.formula} is out of date for {args.version}") + + args.formula.write_text(updated) + print(f"{args.formula} updated to {args.version} ({changed_checksums} checksum(s) changed)") + + +if __name__ == "__main__": + main()