diff --git a/.github/workflows/update-formula.yml b/.github/workflows/update-formula.yml index 61da939..07b64c9 100644 --- a/.github/workflows/update-formula.yml +++ b/.github/workflows/update-formula.yml @@ -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 diff --git a/README.md b/README.md index ed5faf2..e40b8e0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/check_formula.py b/scripts/check_formula.py index 09877e4..a423428 100644 --- a/scripts/check_formula.py +++ b/scripts/check_formula.py @@ -65,6 +65,17 @@ 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 not re.search( + r"on_linux do.*?on_arm do.*?aarch64-unknown-linux-gnu\.tar\.gz", + text, + re.S, + ): + fail( + "Formula/numan.rb Linux ARM URL must be inside an on_linux/on_arm bottle block" + ) + 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"), @@ -82,13 +93,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 "") + + ")" ) diff --git a/scripts/render_homebrew_formula.py b/scripts/render_homebrew_formula.py index 39a1dc3..be52e76 100644 --- a/scripts/render_homebrew_formula.py +++ b/scripts/render_homebrew_formula.py @@ -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 """ @@ -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.+)-(?P" - r"aarch64-apple-darwin|x86_64-unknown-linux-gnu" + r"aarch64-apple-darwin|x86_64-unknown-linux-gnu|aarch64-unknown-linux-gnu" r")\.(?Ptar\.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() @@ -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 @@ -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 @@ -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 the Linux ARM bottle stanza)" + ), + ) parser.add_argument( "--check-url-layout", action="store_true", @@ -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--{triple}.tar.gz") + print("Legacy (--legacy-pre-linux-arm) requires:") + for triple in LEGACY_REQUIRED_ASSETS: + print(f" numan--{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) diff --git a/scripts/test_render_homebrew_formula.py b/scripts/test_render_homebrew_formula.py index 5335ec1..1ba7c6a 100644 --- a/scripts/test_render_homebrew_formula.py +++ b/scripts/test_render_homebrew_formula.py @@ -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) @@ -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" @@ -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() @@ -92,12 +129,15 @@ def test_cli_check_url_layout(self): stdout = buf.getvalue() self.assertIn("numan--aarch64-apple-darwin.tar.gz", stdout) self.assertIn("numan--x86_64-unknown-linux-gnu.tar.gz", stdout) + self.assertIn("numan--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) @@ -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: