diff --git a/.github/scripts/harden-homebrew-formula.py b/.github/scripts/harden-homebrew-formula.py
new file mode 100644
index 0000000..51c419f
--- /dev/null
+++ b/.github/scripts/harden-homebrew-formula.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+from urllib.parse import urlparse
+
+
+SHA_RE = re.compile(r"\b[a-fA-F0-9]{64}\b")
+
+
+def read_sha(path: Path) -> str:
+ match = SHA_RE.search(path.read_text(encoding="utf-8"))
+ if not match:
+ raise SystemExit(f"{path}: no SHA-256 found")
+ return match.group(0).lower()
+
+
+def homebrew_desc(raw: str, formula: Path) -> str:
+ desc = raw.strip().rstrip(".")
+ if len(desc) > 80:
+ for sep in (" — ", " – ", " - ", ": "):
+ if sep in desc:
+ desc = desc.split(sep, 1)[0].strip().rstrip(".")
+ break
+ if len(desc) > 80:
+ raise SystemExit(f"{formula}: Homebrew desc remains over 80 chars")
+ return desc
+
+
+def sha_for_url(url: str, dist_dir: Path, formula: Path) -> str:
+ artifact = Path(urlparse(url).path).name
+ sha_path = dist_dir / f"{artifact}.sha256"
+ if not sha_path.exists():
+ raise SystemExit(f"{formula}: missing checksum file for {artifact}")
+ return read_sha(sha_path)
+
+
+def add_checksums(lines: list[str], dist_dir: Path, formula: Path) -> list[str]:
+ output: list[str] = []
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ url_match = re.match(r'^(\s*)url\s+"([^"]+)"\s*$', line)
+ if not url_match:
+ output.append(line)
+ i += 1
+ continue
+
+ indent, url = url_match.groups()
+ output.append(line)
+ i += 1
+
+ if i < len(lines) and re.match(r"^\s*sha256\s+", lines[i]):
+ i += 1
+
+ output.append(f'{indent}sha256 "{sha_for_url(url, dist_dir, formula)}"')
+
+ return output
+
+
+def normalize_desc_and_version(lines: list[str], formula: Path) -> list[str]:
+ output: list[str] = []
+ for line in lines:
+ if re.match(r'^\s*version\s+"[^"]+"\s*$', line):
+ continue
+
+ desc_match = re.match(r'^(\s*)desc\s+"([^"]*)"\s*$', line)
+ if desc_match:
+ indent, desc = desc_match.groups()
+ output.append(f'{indent}desc "{homebrew_desc(desc, formula)}"')
+ continue
+
+ output.append(line)
+
+ return output
+
+
+def normalize_aliases(text: str) -> str:
+ pattern = re.compile(r" BINARY_ALIASES = \{\n(?P
.*?)\n \}(?:\.freeze)?", re.S)
+ match = pattern.search(text)
+ if not match:
+ return text
+
+ keys = re.findall(r'^\s*"([^"]+)":\s*\{\},?\s*$', match.group("body"), re.M)
+ if not keys:
+ return text
+
+ tokens = [f'"{key}":' for key in keys]
+ width = max(len(token) for token in tokens)
+ body = "\n".join(f" {token}{' ' * (width - len(token) + 1)}{{}}," for token in tokens)
+ replacement = f" BINARY_ALIASES = {{\n{body}\n }}.freeze"
+ return text[: match.start()] + replacement + text[match.end() :]
+
+
+def normalize_install(text: str, binary: str) -> str:
+ pattern = re.compile(r"( def install\n).*?( install_binary_aliases!\n)", re.S)
+ return pattern.sub(rf'\1 bin.install "{binary}"\n\2', text, count=1)
+
+
+def add_test_block(text: str, binary: str) -> str:
+ if re.search(r"^\s*test do\s*$", text, re.M):
+ return text
+
+ block = f'\n test do\n assert_match version.to_s, shell_output("#{{bin}}/{binary} --version")\n end\n'
+ marker = "\nend\n"
+ if not text.endswith(marker):
+ raise SystemExit("formula does not end with a class-level end")
+ return text[: -len(marker)] + block + "end\n"
+
+
+def harden_formula(formula: Path, dist_dir: Path) -> None:
+ lines = formula.read_text(encoding="utf-8").splitlines()
+ lines = normalize_desc_and_version(lines, formula)
+ lines = add_checksums(lines, dist_dir, formula)
+ text = "\n".join(lines) + "\n"
+ text = normalize_aliases(text)
+ text = normalize_install(text, formula.stem)
+ text = add_test_block(text, formula.stem)
+ formula.write_text(text, encoding="utf-8")
+
+
+def main() -> None:
+ if len(sys.argv) != 2:
+ raise SystemExit("usage: harden-homebrew-formula.py ")
+
+ dist_dir = Path(sys.argv[1])
+ formulas = sorted(dist_dir.glob("*.rb"))
+ for formula in formulas:
+ harden_formula(formula, dist_dir)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/rust-packages.yml b/.github/workflows/rust-packages.yml
index d13d7b5..4609e5a 100644
--- a/.github/workflows/rust-packages.yml
+++ b/.github/workflows/rust-packages.yml
@@ -237,6 +237,10 @@ jobs:
cp target/distrib/plan-dist-manifest.json target/distrib/dist-manifest.json
dist build --tag="${GITHUB_REF_NAME}" --artifacts=global --output-format=json > "${RUNNER_TEMP}/dist-manifest.json"
+ - name: Harden Homebrew formula
+ shell: bash
+ run: python3 .github/scripts/harden-homebrew-formula.py target/distrib
+
# Undraft before the formula/npm job so they resolve against a live release, not a draft.
- name: Upload release assets and undraft
shell: bash
@@ -265,9 +269,46 @@ jobs:
target/distrib/*-npm-package.tar.gz
if-no-files-found: ignore
- dist-publish:
+ verify-homebrew-formula:
needs: [dist-plan, dist-host]
- if: ${{ needs.dist-plan.outputs.enabled == 'true' }}
+ if: ${{ needs.dist-plan.outputs.enabled == 'true' && needs.dist-plan.outputs.tap != '' }}
+ runs-on: macos-latest
+ steps:
+ - name: Download global artifacts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: dist-global
+ path: dist-global
+
+ - name: Audit Homebrew formula
+ shell: bash
+ run: |
+ shopt -s nullglob
+ formulas=(dist-global/*.rb)
+ if [ "${#formulas[@]}" -eq 0 ]; then
+ echo "::notice::no Homebrew formula generated — skipping"
+ exit 0
+ fi
+
+ tap="${{ needs.dist-plan.outputs.tap }}"
+ tap_owner="${tap%%/*}"
+ tap_repo="${tap#*/}"
+ tap_name="${tap_repo#homebrew-}"
+ tap_root="$(brew --repository)/Library/Taps/${tap_owner}/${tap_repo}"
+
+ mkdir -p "${tap_root}/Formula"
+ git -C "${tap_root}" init
+
+ for formula in "${formulas[@]}"; do
+ name="$(basename "${formula}" .rb)"
+ cp "${formula}" "${tap_root}/Formula/${name}.rb"
+ brew audit --strict --online "${tap_owner}/${tap_name}/${name}"
+ brew fetch --formula "${tap_owner}/${tap_name}/${name}" --force
+ done
+
+ dist-publish:
+ needs: [dist-plan, dist-host, verify-homebrew-formula]
+ if: ${{ always() && needs.dist-plan.outputs.enabled == 'true' && needs.dist-host.result == 'success' && (needs.verify-homebrew-formula.result == 'success' || needs.verify-homebrew-formula.result == 'skipped') }}
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e07022..12583f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## v0.2.8 - 20/06/2026
+
+### Fixes
+- `rust-packages` — harden cargo-dist Homebrew formulae before release upload and tap publish: add per-platform SHA-256 checksums from the generated `.sha256` files, drop the redundant explicit version, shorten Homebrew `desc`, simplify the binary install block, freeze aliases, and add a `test do` block. A new macOS `verify-homebrew-formula` job runs `brew audit --strict --online` and `brew fetch` before `dist-publish`, so a non-auditable formula cannot reach the tap.
+
## v0.2.7 - 09/06/2026
### Fixes
diff --git a/README.md b/README.md
index a8fece9..f37306c 100644
--- a/README.md
+++ b/README.md
@@ -201,10 +201,11 @@ Verify-builds the packaged crate, so a compile-time asset dropped from the packa
1. `dist-plan` — per-target build matrix
2. `dist-build` — per-target archives
-3. `dist-host` — installers, Homebrew formula, npm shim; uploads assets, undrafts the release
-4. `dist-publish` — tap + npm shim
+3. `dist-host` — installers, hardened Homebrew formula, npm shim; uploads assets, undrafts the release
+4. `verify-homebrew-formula` — `brew audit --strict --online` + `brew fetch`
+5. `dist-publish` — tap + npm shim
-The pipeline owns the single release; cargo-dist only builds. Consumer config — the cargo-dist metadata, with `allow-dirty = ["ci"]` in `[workspace.metadata.dist]`. Per-target features via `cfg`; shared binaries are CPU-only. `HOMEBREW_TAP_TOKEN` / `NPM_PACKAGE_REGISTRY_TOKEN` optional.
+The pipeline owns the single release; cargo-dist only builds. Consumer config — the cargo-dist metadata, with `allow-dirty = ["ci"]` in `[workspace.metadata.dist]`. Per-target features via `cfg`; shared binaries are CPU-only. `HOMEBREW_TAP_TOKEN` / `NPM_PACKAGE_REGISTRY_TOKEN` optional. Homebrew formulae are post-processed before release upload and tap publish so the release asset, tap commit, and audit target stay byte-aligned.
diff --git a/package.json b/package.json
index 8ed0ed3..3034853 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@coroboros/ci",
- "version": "0.2.7",
+ "version": "0.2.8",
"private": true,
"description": "Reusable GitHub Actions CI for the Coroboros stack.",
"license": "SEE LICENSE IN LICENSE.md",