Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 19 additions & 4 deletions .github/workflows/update-formula.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,26 @@ jobs:
'.isDraft == false and any(.assets[]; .name == $asset and .state == "uploaded")' \
>/dev/null
done
python3 scripts/render_homebrew_formula.py \
--version "$VERSION" \
--sha256sums SHA256SUMS \
--write \
RENDER_ARGS=(
--version "$VERSION"
--sha256sums SHA256SUMS
--write
--out Formula/numan.rb
)
# Linux ARM is required for current releases; omit when recovering pre-ARM tags.
linux_arm_asset="numan-${VERSION}-aarch64-unknown-linux-gnu.tar.gz"
linux_arm_ok="$(
gh release view "$RELEASE_TAG" --repo "$NUMAN_REPO" --json isDraft,assets \
| jq -r --arg asset "$linux_arm_asset" \
'if (.isDraft == false and any(.assets[]; .name == $asset and .state == "uploaded")) then "true" else "false" end'
)"
if [[ "$linux_arm_ok" == "true" ]]; then
echo "Found Linux ARM archive ${linux_arm_asset}"
else
echo "No Linux ARM archive on ${RELEASE_TAG}; using --legacy-pre-linux-arm"
RENDER_ARGS+=(--legacy-pre-linux-arm)
fi
python3 scripts/render_homebrew_formula.py "${RENDER_ARGS[@]}"

- name: Commit and push
shell: bash
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,18 @@ brew tap tonythethompson/numan
brew install numan
```

Formula digests are updated automatically by the Numan `Publish to Homebrew tap` workflow after each `v*.*.*` GitHub Release.
## Platforms

| Platform | Status |
|----------|--------|
| macOS Apple Silicon (`aarch64-apple-darwin`) | shipped |
| Linux x86_64 (`x86_64-unknown-linux-gnu`) | shipped |
| Linux ARM64 (`aarch64-unknown-linux-gnu`) | formula support ready; bottle URLs appear on the next numan release that publishes that archive |
| macOS Intel | not shipped (`odie` with cargo install hint) |

Formula digests are updated automatically by the Numan `Publish to Homebrew tap`
workflow (and this repo's `Update numan formula` workflow) after each `v*.*.*`
GitHub Release. Pre-Linux-ARM tags re-render with `--legacy-pre-linux-arm`.

## CI

Expand Down
15 changes: 13 additions & 2 deletions scripts/check_formula.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ def check_static_invariants(text: str) -> None:
if needle not in text:
fail(f"Formula/numan.rb {reason}")

# Linux ARM is optional until a numan release ships aarch64-unknown-linux-gnu.
if "aarch64-unknown-linux-gnu.tar.gz" in text:
if 'on_arm do' not in text:
fail("Formula/numan.rb Linux ARM URL requires an on_arm bottle block")
Comment thread
Copilot marked this conversation as resolved.
Outdated

forbidden = [
("arch_dir", "must not look for a nested numan-* directory (Homebrew stages into it)"),
("expected numan-* directory", "must not look for a nested numan-* directory"),
Expand All @@ -82,13 +87,19 @@ def check_render_roundtrip(version: str) -> None:
sums_text = response.read().decode("utf-8")

mod = load_render_mod()
digests = mod.parse_sha256sums(sums_text, version)
linux_arm_asset = f"numan-{version}-{mod.LINUX_ARM_ASSET}.tar.gz"
legacy = linux_arm_asset not in sums_text
digests = mod.parse_sha256sums(
sums_text, version, legacy_pre_linux_arm=legacy
)
expected = mod.render_formula(version, digests)
actual = FORMULA_PATH.read_text(encoding="utf-8")
if actual != expected:
fail(
"Formula/numan.rb does not match scripts/render_homebrew_formula.py "
f"output for v{version} (re-render from SHA256SUMS)"
f"output for v{version} (re-render from SHA256SUMS"
+ ("; used --legacy-pre-linux-arm" if legacy else "")
+ ")"
)


Expand Down
58 changes: 47 additions & 11 deletions scripts/render_homebrew_formula.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""Render packaging/homebrew/numan.rb from a release version + SHA256SUMS.
"""Render Formula/numan.rb from a release version + SHA256SUMS.

Usage:
python scripts/render_homebrew_formula.py --version 0.1.5 --sha256sums SHA256SUMS
python scripts/render_homebrew_formula.py --version 0.1.5 --sha256sums SHA256SUMS --write
python scripts/render_homebrew_formula.py --version 0.2.2 --sha256sums SHA256SUMS
python scripts/render_homebrew_formula.py --version 0.2.2 --sha256sums SHA256SUMS --write
python scripts/render_homebrew_formula.py --version 0.1.5 --sha256sums SHA256SUMS --legacy-pre-linux-arm
python scripts/render_homebrew_formula.py --check-url-layout # verify known asset names
"""

Expand All @@ -17,22 +18,34 @@
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUT = REPO_ROOT / "Formula" / "numan.rb"

# Pre-Linux-ARM Homebrew contract (macOS ARM + Linux x86_64 only).
LEGACY_REQUIRED_ASSETS = {
"aarch64-apple-darwin": "macos_arm",
"x86_64-unknown-linux-gnu": "linux_intel",
}

# Current Homebrew contract adds Linux aarch64.
LINUX_ARM_ASSET = "aarch64-unknown-linux-gnu"

# Release asset basename suffix -> Homebrew bottle platform key used in formula.
# Intel Mac (x86_64-apple-darwin) is intentionally unsupported for shipping.
REQUIRED_ASSETS = {
"aarch64-apple-darwin": "macos_arm",
"x86_64-unknown-linux-gnu": "linux_intel",
**LEGACY_REQUIRED_ASSETS,
LINUX_ARM_ASSET: "linux_arm",
}

ASSET_RE = re.compile(
r"^([0-9a-fA-F]{64})\s+numan-(?P<ver>.+)-(?P<triple>"
r"aarch64-apple-darwin|x86_64-unknown-linux-gnu"
r"aarch64-apple-darwin|x86_64-unknown-linux-gnu|aarch64-unknown-linux-gnu"
r")\.(?P<ext>tar\.gz|zip)$"
)


def parse_sha256sums(text: str, version: str) -> dict[str, str]:
def parse_sha256sums(
text: str, version: str, *, legacy_pre_linux_arm: bool = False
) -> dict[str, str]:
"""Map triple -> lowercase sha256 for this version's archives."""
required = LEGACY_REQUIRED_ASSETS if legacy_pre_linux_arm else REQUIRED_ASSETS
found: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
Expand All @@ -46,17 +59,25 @@ def parse_sha256sums(text: str, version: str) -> dict[str, str]:
if match.group("ext") != "tar.gz":
continue
found[match.group("triple")] = match.group(1).lower()
missing = [t for t in REQUIRED_ASSETS if t not in found]
missing = [t for t in required if t not in found]
if missing:
raise SystemExit(
f"SHA256SUMS missing required .tar.gz assets for v{version}: {', '.join(missing)}"
)
return found
return {t: found[t] for t in required}


def render_formula(version: str, digests: dict[str, str]) -> str:
mac_arm = digests["aarch64-apple-darwin"]
linux_intel = digests["x86_64-unknown-linux-gnu"]
linux_arm = digests.get(LINUX_ARM_ASSET)
linux_arm_block = ""
if linux_arm is not None:
linux_arm_block = f"""
on_arm do
url "https://github.com/tonythethompson/numan/releases/download/v#{{version}}/numan-#{{version}}-aarch64-unknown-linux-gnu.tar.gz"
sha256 "{linux_arm}"
end"""
return f"""# typed: false
# frozen_string_literal: true

Expand Down Expand Up @@ -90,7 +111,7 @@ class Numan < Formula
on_intel do
url "https://github.com/tonythethompson/numan/releases/download/v#{{version}}/numan-#{{version}}-x86_64-unknown-linux-gnu.tar.gz"
sha256 "{linux_intel}"
end
end{linux_arm_block}
end

def install
Expand Down Expand Up @@ -125,6 +146,14 @@ def main(argv: list[str] | None = None) -> int:
default=DEFAULT_OUT,
help="Output formula path",
)
parser.add_argument(
"--legacy-pre-linux-arm",
action="store_true",
help=(
"Accept the pre-Linux-ARM Homebrew asset set "
"(macOS ARM + Linux x86_64 only; omit on_linux/on_arm)"
),
)
Comment thread
Copilot marked this conversation as resolved.
parser.add_argument(
"--check-url-layout",
action="store_true",
Expand All @@ -136,12 +165,19 @@ def main(argv: list[str] | None = None) -> int:
print("Required release assets for Homebrew:")
for triple in REQUIRED_ASSETS:
print(f" numan-<version>-{triple}.tar.gz")
print("Legacy (--legacy-pre-linux-arm) requires:")
for triple in LEGACY_REQUIRED_ASSETS:
print(f" numan-<version>-{triple}.tar.gz")
return 0

if not args.version or not args.sha256sums:
parser.error("--version and --sha256sums are required unless --check-url-layout")

digests = parse_sha256sums(args.sha256sums.read_text(encoding="utf-8"), args.version)
digests = parse_sha256sums(
args.sha256sums.read_text(encoding="utf-8"),
args.version,
legacy_pre_linux_arm=args.legacy_pre_linux_arm,
)
text = render_formula(args.version, digests)
if args.write:
args.out.parent.mkdir(parents=True, exist_ok=True)
Expand Down
67 changes: 67 additions & 0 deletions scripts/test_render_homebrew_formula.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,27 @@ def test_parse_and_render(self):
4d8fa065b5bc7fcce30af3ca7d5c3cd943701bee168d78fae6120a12689738b8 numan-0.1.5-aarch64-apple-darwin.tar.gz
2d855b3b8a9bb3c568051024b8aae9771a63c427cb3edfd3ac3aa3aa6d78468d numan-0.1.5-x86_64-pc-windows-msvc.zip
0b113361c189a2062ef6e1fca36795d2347b925c1862b42c4ddeb54773e00ae3 numan-0.1.5-x86_64-unknown-linux-gnu.tar.gz
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.1.5-aarch64-unknown-linux-gnu.tar.gz
""".strip()
digests = self.mod.parse_sha256sums(sums, "0.1.5")
self.assertEqual(
digests["aarch64-apple-darwin"],
"4d8fa065b5bc7fcce30af3ca7d5c3cd943701bee168d78fae6120a12689738b8",
)
self.assertEqual(
digests["aarch64-unknown-linux-gnu"],
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
)
text = self.mod.render_formula("0.1.5", digests)
self.assertIn('version "0.1.5"', text)
self.assertIn("numan-#{version}-aarch64-apple-darwin.tar.gz", text)
self.assertIn("numan-#{version}-aarch64-unknown-linux-gnu.tar.gz", text)
self.assertIn(
"4d8fa065b5bc7fcce30af3ca7d5c3cd943701bee168d78fae6120a12689738b8", text
)
self.assertIn(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", text
)
self.assertIn('bin.install "numan"', text)
self.assertNotIn("arch_dir", text)
self.assertNotIn("expected numan-* directory", text)
Expand All @@ -51,26 +60,53 @@ def test_parse_and_render(self):
self.assertNotIn("x86_64-apple-darwin.tar.gz", text)
self.assertNotIn("windows-msvc", text)

def test_legacy_pre_linux_arm_omits_linux_arm_bottle(self):
sums = """
4d8fa065b5bc7fcce30af3ca7d5c3cd943701bee168d78fae6120a12689738b8 numan-0.1.5-aarch64-apple-darwin.tar.gz
0b113361c189a2062ef6e1fca36795d2347b925c1862b42c4ddeb54773e00ae3 numan-0.1.5-x86_64-unknown-linux-gnu.tar.gz
""".strip()
digests = self.mod.parse_sha256sums(
sums, "0.1.5", legacy_pre_linux_arm=True
)
self.assertNotIn("aarch64-unknown-linux-gnu", digests)
text = self.mod.render_formula("0.1.5", digests)
self.assertIn("x86_64-unknown-linux-gnu.tar.gz", text)
self.assertNotIn("aarch64-unknown-linux-gnu.tar.gz", text)

def test_parse_prerelease_version(self):
sums = """
4D8FA065B5BC7FCCE30AF3CA7D5C3CD943701BEE168D78FAE6120A12689738B8 numan-0.2.0-beta.1-aarch64-apple-darwin.tar.gz
2d855b3b8a9bb3c568051024b8aae9771a63c427cb3edfd3ac3aa3aa6d78468d numan-0.2.0-beta.1-x86_64-unknown-linux-gnu.tar.gz
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-beta.1-aarch64-unknown-linux-gnu.tar.gz
""".strip()
digests = self.mod.parse_sha256sums(sums, "0.2.0-beta.1")
self.assertEqual(
digests["aarch64-apple-darwin"],
"4d8fa065b5bc7fcce30af3ca7d5c3cd943701bee168d78fae6120a12689738b8",
)
self.assertEqual(
digests["aarch64-unknown-linux-gnu"],
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
)

def test_missing_asset_fails(self):
sums = "abcd numan-0.1.5-aarch64-apple-darwin.tar.gz\n"
with self.assertRaises(SystemExit):
self.mod.parse_sha256sums(sums, "0.1.5")

def test_missing_linux_arm_fails_without_legacy(self):
sums = """
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa numan-0.2.0-aarch64-apple-darwin.tar.gz
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc numan-0.2.0-x86_64-unknown-linux-gnu.tar.gz
""".strip()
with self.assertRaises(SystemExit):
self.mod.parse_sha256sums(sums, "0.2.0")

def test_write_roundtrip(self):
sums = """
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa numan-0.2.0-aarch64-apple-darwin.tar.gz
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc numan-0.2.0-x86_64-unknown-linux-gnu.tar.gz
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-aarch64-unknown-linux-gnu.tar.gz
""".strip()
with tempfile.TemporaryDirectory() as tmp:
sums_path = Path(tmp) / "SHA256SUMS"
Expand All @@ -83,6 +119,7 @@ def test_write_roundtrip(self):
body = out.read_text(encoding="utf-8")
self.assertEqual(body, text)
self.assertIn('version "0.2.0"', body)
self.assertIn("aarch64-unknown-linux-gnu.tar.gz", body)

def test_cli_check_url_layout(self):
buf = io.StringIO()
Expand All @@ -92,12 +129,15 @@ def test_cli_check_url_layout(self):
stdout = buf.getvalue()
self.assertIn("numan-<version>-aarch64-apple-darwin.tar.gz", stdout)
self.assertIn("numan-<version>-x86_64-unknown-linux-gnu.tar.gz", stdout)
self.assertIn("numan-<version>-aarch64-unknown-linux-gnu.tar.gz", stdout)
self.assertIn("--legacy-pre-linux-arm", stdout)
self.assertNotIn("x86_64-apple-darwin", stdout)

def test_cli_full_write(self):
sums = """
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa numan-0.2.0-aarch64-apple-darwin.tar.gz
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc numan-0.2.0-x86_64-unknown-linux-gnu.tar.gz
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-aarch64-unknown-linux-gnu.tar.gz
""".strip()
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
Expand All @@ -121,8 +161,35 @@ def test_cli_full_write(self):
self.assertTrue(out_path.is_file())
text = out_path.read_text(encoding="utf-8")
self.assertIn('version "0.2.0"', text)
self.assertIn("aarch64-unknown-linux-gnu.tar.gz", text)
self.assertIn("Wrote ", buf.getvalue())

def test_cli_legacy_write(self):
sums = """
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa numan-0.1.5-aarch64-apple-darwin.tar.gz
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc numan-0.1.5-x86_64-unknown-linux-gnu.tar.gz
""".strip()
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
sums_path = tmp_path / "SHA256SUMS"
out_path = tmp_path / "numan.rb"
sums_path.write_text(sums + "\n", encoding="utf-8")
code = self.mod.main(
[
"--version",
"0.1.5",
"--sha256sums",
str(sums_path),
"--legacy-pre-linux-arm",
"--write",
"--out",
str(out_path),
]
)
self.assertEqual(0, code)
text = out_path.read_text(encoding="utf-8")
self.assertNotIn("aarch64-unknown-linux-gnu", text)

def test_cli_missing_required_args_fails(self):
err = io.StringIO()
with redirect_stderr(err), self.assertRaises(SystemExit) as cm:
Expand Down
Loading