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",