Skip to content
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
135 changes: 135 additions & 0 deletions .github/scripts/harden-homebrew-formula.py
Original file line number Diff line number Diff line change
@@ -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<body>.*?)\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>")

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()
45 changes: 43 additions & 2 deletions .github/workflows/rust-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading