Skip to content

Commit 0ba19c6

Browse files
authored
fix(rust): harden Homebrew formula publishing (#35)
1 parent 7a7603b commit 0ba19c6

5 files changed

Lines changed: 188 additions & 6 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import re
5+
import sys
6+
from pathlib import Path
7+
from urllib.parse import urlparse
8+
9+
10+
SHA_RE = re.compile(r"\b[a-fA-F0-9]{64}\b")
11+
12+
13+
def read_sha(path: Path) -> str:
14+
match = SHA_RE.search(path.read_text(encoding="utf-8"))
15+
if not match:
16+
raise SystemExit(f"{path}: no SHA-256 found")
17+
return match.group(0).lower()
18+
19+
20+
def homebrew_desc(raw: str, formula: Path) -> str:
21+
desc = raw.strip().rstrip(".")
22+
if len(desc) > 80:
23+
for sep in (" — ", " – ", " - ", ": "):
24+
if sep in desc:
25+
desc = desc.split(sep, 1)[0].strip().rstrip(".")
26+
break
27+
if len(desc) > 80:
28+
raise SystemExit(f"{formula}: Homebrew desc remains over 80 chars")
29+
return desc
30+
31+
32+
def sha_for_url(url: str, dist_dir: Path, formula: Path) -> str:
33+
artifact = Path(urlparse(url).path).name
34+
sha_path = dist_dir / f"{artifact}.sha256"
35+
if not sha_path.exists():
36+
raise SystemExit(f"{formula}: missing checksum file for {artifact}")
37+
return read_sha(sha_path)
38+
39+
40+
def add_checksums(lines: list[str], dist_dir: Path, formula: Path) -> list[str]:
41+
output: list[str] = []
42+
i = 0
43+
while i < len(lines):
44+
line = lines[i]
45+
url_match = re.match(r'^(\s*)url\s+"([^"]+)"\s*$', line)
46+
if not url_match:
47+
output.append(line)
48+
i += 1
49+
continue
50+
51+
indent, url = url_match.groups()
52+
output.append(line)
53+
i += 1
54+
55+
if i < len(lines) and re.match(r"^\s*sha256\s+", lines[i]):
56+
i += 1
57+
58+
output.append(f'{indent}sha256 "{sha_for_url(url, dist_dir, formula)}"')
59+
60+
return output
61+
62+
63+
def normalize_desc_and_version(lines: list[str], formula: Path) -> list[str]:
64+
output: list[str] = []
65+
for line in lines:
66+
if re.match(r'^\s*version\s+"[^"]+"\s*$', line):
67+
continue
68+
69+
desc_match = re.match(r'^(\s*)desc\s+"([^"]*)"\s*$', line)
70+
if desc_match:
71+
indent, desc = desc_match.groups()
72+
output.append(f'{indent}desc "{homebrew_desc(desc, formula)}"')
73+
continue
74+
75+
output.append(line)
76+
77+
return output
78+
79+
80+
def normalize_aliases(text: str) -> str:
81+
pattern = re.compile(r" BINARY_ALIASES = \{\n(?P<body>.*?)\n \}(?:\.freeze)?", re.S)
82+
match = pattern.search(text)
83+
if not match:
84+
return text
85+
86+
keys = re.findall(r'^\s*"([^"]+)":\s*\{\},?\s*$', match.group("body"), re.M)
87+
if not keys:
88+
return text
89+
90+
tokens = [f'"{key}":' for key in keys]
91+
width = max(len(token) for token in tokens)
92+
body = "\n".join(f" {token}{' ' * (width - len(token) + 1)}{{}}," for token in tokens)
93+
replacement = f" BINARY_ALIASES = {{\n{body}\n }}.freeze"
94+
return text[: match.start()] + replacement + text[match.end() :]
95+
96+
97+
def normalize_install(text: str, binary: str) -> str:
98+
pattern = re.compile(r"( def install\n).*?( install_binary_aliases!\n)", re.S)
99+
return pattern.sub(rf'\1 bin.install "{binary}"\n\2', text, count=1)
100+
101+
102+
def add_test_block(text: str, binary: str) -> str:
103+
if re.search(r"^\s*test do\s*$", text, re.M):
104+
return text
105+
106+
block = f'\n test do\n assert_match version.to_s, shell_output("#{{bin}}/{binary} --version")\n end\n'
107+
marker = "\nend\n"
108+
if not text.endswith(marker):
109+
raise SystemExit("formula does not end with a class-level end")
110+
return text[: -len(marker)] + block + "end\n"
111+
112+
113+
def harden_formula(formula: Path, dist_dir: Path) -> None:
114+
lines = formula.read_text(encoding="utf-8").splitlines()
115+
lines = normalize_desc_and_version(lines, formula)
116+
lines = add_checksums(lines, dist_dir, formula)
117+
text = "\n".join(lines) + "\n"
118+
text = normalize_aliases(text)
119+
text = normalize_install(text, formula.stem)
120+
text = add_test_block(text, formula.stem)
121+
formula.write_text(text, encoding="utf-8")
122+
123+
124+
def main() -> None:
125+
if len(sys.argv) != 2:
126+
raise SystemExit("usage: harden-homebrew-formula.py <dist-dir>")
127+
128+
dist_dir = Path(sys.argv[1])
129+
formulas = sorted(dist_dir.glob("*.rb"))
130+
for formula in formulas:
131+
harden_formula(formula, dist_dir)
132+
133+
134+
if __name__ == "__main__":
135+
main()

.github/workflows/rust-packages.yml

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ jobs:
237237
cp target/distrib/plan-dist-manifest.json target/distrib/dist-manifest.json
238238
dist build --tag="${GITHUB_REF_NAME}" --artifacts=global --output-format=json > "${RUNNER_TEMP}/dist-manifest.json"
239239
240+
- name: Harden Homebrew formula
241+
shell: bash
242+
run: python3 .github/scripts/harden-homebrew-formula.py target/distrib
243+
240244
# Undraft before the formula/npm job so they resolve against a live release, not a draft.
241245
- name: Upload release assets and undraft
242246
shell: bash
@@ -265,9 +269,46 @@ jobs:
265269
target/distrib/*-npm-package.tar.gz
266270
if-no-files-found: ignore
267271

268-
dist-publish:
272+
verify-homebrew-formula:
269273
needs: [dist-plan, dist-host]
270-
if: ${{ needs.dist-plan.outputs.enabled == 'true' }}
274+
if: ${{ needs.dist-plan.outputs.enabled == 'true' && needs.dist-plan.outputs.tap != '' }}
275+
runs-on: macos-latest
276+
steps:
277+
- name: Download global artifacts
278+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
279+
with:
280+
name: dist-global
281+
path: dist-global
282+
283+
- name: Audit Homebrew formula
284+
shell: bash
285+
run: |
286+
shopt -s nullglob
287+
formulas=(dist-global/*.rb)
288+
if [ "${#formulas[@]}" -eq 0 ]; then
289+
echo "::notice::no Homebrew formula generated — skipping"
290+
exit 0
291+
fi
292+
293+
tap="${{ needs.dist-plan.outputs.tap }}"
294+
tap_owner="${tap%%/*}"
295+
tap_repo="${tap#*/}"
296+
tap_name="${tap_repo#homebrew-}"
297+
tap_root="$(brew --repository)/Library/Taps/${tap_owner}/${tap_repo}"
298+
299+
mkdir -p "${tap_root}/Formula"
300+
git -C "${tap_root}" init
301+
302+
for formula in "${formulas[@]}"; do
303+
name="$(basename "${formula}" .rb)"
304+
cp "${formula}" "${tap_root}/Formula/${name}.rb"
305+
brew audit --strict --online "${tap_owner}/${tap_name}/${name}"
306+
brew fetch --formula "${tap_owner}/${tap_name}/${name}" --force
307+
done
308+
309+
dist-publish:
310+
needs: [dist-plan, dist-host, verify-homebrew-formula]
311+
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') }}
271312
runs-on: ubuntu-latest
272313
permissions:
273314
contents: read

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## v0.2.8 - 20/06/2026
4+
5+
### Fixes
6+
- `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.
7+
38
## v0.2.7 - 09/06/2026
49

510
### Fixes

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,11 @@ Verify-builds the packaged crate, so a compile-time asset dropped from the packa
201201

202202
1. `dist-plan` — per-target build matrix
203203
2. `dist-build` — per-target archives
204-
3. `dist-host` — installers, Homebrew formula, npm shim; uploads assets, undrafts the release
205-
4. `dist-publish` — tap + npm shim
204+
3. `dist-host` — installers, hardened Homebrew formula, npm shim; uploads assets, undrafts the release
205+
4. `verify-homebrew-formula``brew audit --strict --online` + `brew fetch`
206+
5. `dist-publish` — tap + npm shim
206207

207-
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.
208+
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.
208209

209210
</details>
210211

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@coroboros/ci",
3-
"version": "0.2.7",
3+
"version": "0.2.8",
44
"private": true,
55
"description": "Reusable GitHub Actions CI for the Coroboros stack.",
66
"license": "SEE LICENSE IN LICENSE.md",

0 commit comments

Comments
 (0)