You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tsfile.apache.org by co...@apache.org on 2026/09/17 06:30:59 UTC

(tsfile) 16/24: feat(ci): build Homebrew development bottles

This is an automated email from the ASF dual-hosted git repository.

ColinLeeo pushed a commit to branch feat/native-package-artifacts
in repository https://gitbox.apache.org/repos/asf/tsfile.git

commit 8b6966a76da397269b22d09e4628de5c611e59ab
Author: ColinLee <sh...@163.com>
AuthorDate: Thu Sep 10 19:19:17 2026 +0800

    feat(ci): build Homebrew development bottles
---
 .github/workflows/native-packages.yml           | 183 ++++++++++++++++++++++++
 packaging/README.md                             |  24 ++++
 packaging/homebrew/tsfile-dev.rb.in             |  66 +++++++++
 packaging/scripts/render_homebrew_formula.py    |  83 +++++++++++
 packaging/tests/test_render_homebrew_formula.py | 177 +++++++++++++++++++++++
 5 files changed, 533 insertions(+)

diff --git a/.github/workflows/native-packages.yml b/.github/workflows/native-packages.yml
index fdc560e3a..019918a70 100644
--- a/.github/workflows/native-packages.yml
+++ b/.github/workflows/native-packages.yml
@@ -476,3 +476,186 @@ jobs:
           path: packages/*.zip
           if-no-files-found: error
           retention-days: 14
+
+  build-homebrew:
+    name: Build Homebrew bottle on ${{ matrix.name }}
+    needs: prepare
+    runs-on: ${{ matrix.os }}
+    timeout-minutes: 45
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - name: macos-arm64
+            os: macos-latest
+          - name: macos-x86_64
+            os: macos-15-intel
+    env:
+      HOMEBREW_NO_AUTO_UPDATE: "1"
+      HOMEBREW_NO_INSTALL_CLEANUP: "1"
+      TSFILE_HOMEBREW_VERSION: ${{ needs.prepare.outputs.homebrew_version }}
+      SOURCE_REPOSITORY: ColinLeeo/tsfile
+      SOURCE_GIT_SHA: ${{ github.sha }}
+      BOTTLE_ROOT_URL: https://packages.apache.org/artifactory/tsfile/homebrew/dev/versions/${{ needs.prepare.outputs.homebrew_version }}/bottles
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v7
+
+      - name: Render immutable source Formula in a temporary local tap
+        shell: bash
+        run: |
+          mkdir -p homebrew/Formula homebrew/bottles
+          curl --fail --location --retry 3 \
+            "https://github.com/$SOURCE_REPOSITORY/archive/$SOURCE_GIT_SHA.tar.gz" \
+            --output "$RUNNER_TEMP/tsfile-source.tar.gz"
+          source_sha256="$(shasum -a 256 "$RUNNER_TEMP/tsfile-source.tar.gz" | awk '{print $1}')"
+          python3 packaging/scripts/render_homebrew_formula.py \
+            --template packaging/homebrew/tsfile-dev.rb.in \
+            --repository "$SOURCE_REPOSITORY" \
+            --git-sha "$SOURCE_GIT_SHA" \
+            --version "$TSFILE_HOMEBREW_VERSION" \
+            --source-sha256 "$source_sha256" \
+            --output homebrew/Formula/tsfile-dev.rb
+          ruby -c homebrew/Formula/tsfile-dev.rb
+          brew tap-new apache/tsfile-dev
+          tap_path="$(brew --repository apache/tsfile-dev)"
+          cp homebrew/Formula/tsfile-dev.rb "$tap_path/Formula/tsfile-dev.rb"
+
+      - name: Build, test, and bottle the development Formula
+        shell: bash
+        run: |
+          brew install --build-bottle apache/tsfile-dev/tsfile-dev
+          brew test apache/tsfile-dev/tsfile-dev
+          cd homebrew/bottles
+          brew bottle --json --root-url "$BOTTLE_ROOT_URL" apache/tsfile-dev/tsfile-dev
+          shopt -s nullglob
+          bottles=(*.bottle*.tar.gz)
+          metadata=(*.bottle.json)
+          [[ ${#bottles[@]} -eq 1 && ${#metadata[@]} -eq 1 ]]
+
+      - name: Upload platform Homebrew bottle and source Formula
+        uses: actions/upload-artifact@v7
+        with:
+          name: native-homebrew-${{ matrix.name }}
+          path: homebrew/
+          if-no-files-found: error
+          retention-days: 14
+
+  merge-homebrew:
+    name: Merge Homebrew development bottles
+    needs: [prepare, build-homebrew]
+    runs-on: macos-latest
+    timeout-minutes: 15
+    env:
+      HOMEBREW_NO_AUTO_UPDATE: "1"
+      TSFILE_HOMEBREW_VERSION: ${{ needs.prepare.outputs.homebrew_version }}
+      BOTTLE_ROOT_URL: https://packages.apache.org/artifactory/tsfile/homebrew/dev/versions/${{ needs.prepare.outputs.homebrew_version }}/bottles
+    steps:
+      - name: Download both platform Homebrew artifacts separately
+        uses: actions/download-artifact@v8
+        with:
+          pattern: native-homebrew-macos-*
+          path: homebrew-intermediate
+          merge-multiple: false
+
+      - name: Recreate the same pre-bottle Formula and collect metadata
+        shell: bash
+        run: |
+          arm=homebrew-intermediate/native-homebrew-macos-arm64
+          intel=homebrew-intermediate/native-homebrew-macos-x86_64
+          cmp "$arm/Formula/tsfile-dev.rb" "$intel/Formula/tsfile-dev.rb"
+          brew tap-new apache/tsfile-dev
+          tap_path="$(brew --repository apache/tsfile-dev)"
+          cp "$arm/Formula/tsfile-dev.rb" "$tap_path/Formula/tsfile-dev.rb"
+          mkdir -p homebrew/Formula homebrew/bottles
+          python3 - <<'PY'
+          import hashlib
+          import json
+          import os
+          import shutil
+          from pathlib import Path
+
+          destination = Path("homebrew/bottles")
+          tags = set()
+          for platform in ("macos-arm64", "macos-x86_64"):
+              directory = Path("homebrew-intermediate") / f"native-homebrew-{platform}" / "bottles"
+              metadata = list(directory.glob("*.bottle.json"))
+              assert len(metadata) == 1, f"Expected one JSON for {platform}"
+              data = json.loads(metadata[0].read_text())
+              assert list(data) == ["apache/tsfile-dev/tsfile-dev"]
+              entry = data["apache/tsfile-dev/tsfile-dev"]
+              assert entry["formula"]["pkg_version"] == os.environ["TSFILE_HOMEBREW_VERSION"]
+              assert entry["formula"]["path"] == "Library/Taps/apache/homebrew-tsfile-dev/Formula/tsfile-dev.rb"
+              assert entry["bottle"]["root_url"] == os.environ["BOTTLE_ROOT_URL"]
+              assert len(entry["bottle"]["tags"]) == 1
+              tag, bottle = next(iter(entry["bottle"]["tags"].items()))
+              assert (platform == "macos-arm64") == tag.startswith("arm64_")
+              assert tag not in tags, f"Duplicate bottle tag: {tag}"
+              tags.add(tag)
+              local_name = bottle["local_filename"]
+              assert Path(local_name).name == local_name
+              source = directory / local_name
+              assert hashlib.sha256(source.read_bytes()).hexdigest() == bottle["sha256"]
+              for source in (source, metadata[0]):
+                  target = destination / source.name
+                  assert not target.exists(), f"Duplicate artifact: {target}"
+                  shutil.copy2(source, target)
+          assert len(tags) == 2
+          PY
+
+      - name: Merge both bottle tags into the Formula
+        shell: bash
+        run: |
+          brew bottle --merge --write --no-commit homebrew/bottles/*.json
+          tap_path="$(brew --repository apache/tsfile-dev)"
+          cp "$tap_path/Formula/tsfile-dev.rb" homebrew/Formula/tsfile-dev.rb
+          ruby -c homebrew/Formula/tsfile-dev.rb
+
+      - name: Verify merged Formula and normalize HTTP bottle filenames
+        shell: bash
+        run: |
+          python3 - <<'PY'
+          import hashlib
+          import json
+          import os
+          import re
+          from pathlib import Path
+          from urllib.parse import unquote
+
+          formula = Path("homebrew/Formula/tsfile-dev.rb").read_text()
+          assert not re.search(r"@[A-Z0-9_]+@", formula), "Unresolved Formula token"
+          assert len(re.findall(r"^\s*bottle\s+do\b", formula, re.M)) == 1
+          assert f'root_url "{os.environ["BOTTLE_ROOT_URL"]}"' in formula
+          directory = Path("homebrew/bottles")
+          metadata = list(directory.glob("*.bottle.json"))
+          assert len(metadata) == 2
+          tags = set()
+          for path in metadata:
+              data = json.loads(path.read_text())
+              entry = data["apache/tsfile-dev/tsfile-dev"]
+              for tag, bottle in entry["bottle"]["tags"].items():
+                  assert tag not in tags, f"Duplicate bottle tag: {tag}"
+                  tags.add(tag)
+                  assert re.search(rf'\b{re.escape(tag)}:\s*"{bottle["sha256"]}"', formula)
+                  # Homebrew emits a double-dash local name but serves a single-dash URL.
+                  filename = unquote(bottle["filename"])
+                  assert Path(filename).name == filename and filename.endswith(".tar.gz")
+                  source = directory / bottle["local_filename"]
+                  target = directory / filename
+                  assert hashlib.sha256(source.read_bytes()).hexdigest() == bottle["sha256"]
+                  if source != target:
+                      assert not target.exists(), f"Duplicate HTTP bottle name: {filename}"
+                      source.rename(target)
+                  bottle["local_filename"] = filename
+              path.write_text(json.dumps(data, indent=2) + "\n")
+          assert len(tags) == 2
+          assert len(list(directory.glob("*.tar.gz"))) == 2
+          PY
+
+      - name: Upload merged Homebrew Formula and bottles
+        uses: actions/upload-artifact@v7
+        with:
+          name: native-homebrew
+          path: homebrew/
+          if-no-files-found: error
+          retention-days: 14
diff --git a/packaging/README.md b/packaging/README.md
index 8045a4f11..bf4afaee5 100644
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -94,6 +94,30 @@ brew install ./packaging/homebrew/tsfile.rb
 The stable formula URL and checksum must be updated to the ASF source archive
 when the first release containing this packaging work is published.
 
+The manual native-package workflow also builds the self-hosted development
+Formula `tsfile-dev`, using `packaging/homebrew/tsfile-dev.rb.in`. This is not a
+Homebrew/core submission. Each build pins the full workflow commit from
+`ColinLeeo/tsfile`, hashes that source archive, and uses the generated immutable
+Homebrew development version. The Formula retains the CMake install behavior
+and tests the installed C++ consumer and CLI before bottling.
+
+The ARM64 job runs on `macos-latest` and the Intel job on `macos-15-intel`.
+Their `native-homebrew-macos-arm64` and `native-homebrew-macos-x86_64`
+intermediate artifacts remain separate until merge. The merge job checks that
+both source Formula files match, merges both platform JSON files with Homebrew,
+and checks both generated tags and checksums in the resulting Formula.
+
+The `native-homebrew` artifact contains `Formula/tsfile-dev.rb` and `bottles/`
+with both bottle tarballs and both JSON metadata files. When downloaded into
+`homebrew/`, this gives the final `homebrew/Formula/` and `homebrew/bottles/`
+layout. The configured future bottle root is
+`https://packages.apache.org/artifactory/tsfile/homebrew/dev/versions/<homebrew-version>/bottles`.
+Homebrew generates local tarballs with a double dash before the version but
+requests a single dash in HTTP URLs. After merge, the workflow renames each
+tarball to the JSON `filename` (URL-decoded) and updates its `local_filename`
+to match, preserving its checksum. The workflow only uploads GitHub Actions
+artifacts for 14 days; it does not publish to that root or update `latest`.
+
 ## Windows
 
 The manual workflow builds a 64-bit Release package with Visual Studio 2022 and
diff --git a/packaging/homebrew/tsfile-dev.rb.in b/packaging/homebrew/tsfile-dev.rb.in
new file mode 100644
index 000000000..cc5b60bb2
--- /dev/null
+++ b/packaging/homebrew/tsfile-dev.rb.in
@@ -0,0 +1,66 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+class TsfileDev < Formula
+  desc "Columnar storage library for time series data (development snapshot)"
+  homepage "https://tsfile.apache.org/"
+  url "https://github.com/@SOURCE_REPOSITORY@/archive/@GIT_SHA@.tar.gz"
+  version "@HOMEBREW_VERSION@"
+  sha256 "@SOURCE_SHA256@"
+  license "Apache-2.0"
+
+@BOTTLE_BLOCK@
+
+  depends_on "cmake" => :build
+  depends_on "lz4"
+  depends_on "simde"
+  depends_on "snappy"
+  depends_on "utf8cpp"
+  depends_on "zstd"
+
+  uses_from_macos "zlib"
+
+  def install
+    args = %W[
+      -DBUILD_TEST=OFF
+      -DBUILD_TOOLS=ON
+      -DENABLE_LZMA2=OFF
+      -DTSFILE_DEPENDENCY_SOURCE=AUTO
+      -DTSFILE_ENABLE_NATIVE_ARCH=OFF
+      -DCMAKE_INSTALL_RPATH=#{rpath}
+    ]
+
+    system "cmake", "-S", "cpp", "-B", "build", *args, *std_cmake_args
+    system "cmake", "--build", "build"
+    system "cmake", "--install", "build"
+  end
+
+  test do
+    (testpath / "test.cpp").write <<~CPP
+      #include <tsfile/cwrapper/tsfile_cwrapper.h>
+
+      int main() {
+        return TS_DATATYPE_INT32 == 1 ? 0 : 1;
+      }
+    CPP
+
+    system ENV.cxx, "-std=c++11", "test.cpp", "-I#{include}", "-L#{lib}",
+           "-Wl,-rpath,#{lib}", "-ltsfile", "-o", "test"
+    system "./test"
+    system bin / "tsfile-cli", "--version"
+  end
+end
diff --git a/packaging/scripts/render_homebrew_formula.py b/packaging/scripts/render_homebrew_formula.py
new file mode 100644
index 000000000..a39ba31ee
--- /dev/null
+++ b/packaging/scripts/render_homebrew_formula.py
@@ -0,0 +1,83 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Render the development Formula using an immutable source identity."""
+
+import argparse
+import re
+from pathlib import Path
+
+
+VALUE_PATTERNS = {
+    "SOURCE_REPOSITORY": r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+",
+    "GIT_SHA": r"[0-9a-f]{40}",
+    "HOMEBREW_VERSION": r"[0-9][A-Za-z0-9.]*",
+    "SOURCE_SHA256": r"[0-9a-f]{64}",
+}
+TOKEN = re.compile(r"@([A-Z0-9_]+)@")
+
+
+def render_formula(
+    template: str, values: dict[str, str], bottle_block: str = ""
+) -> str:
+    """Replace declared tokens without interpreting Ruby braces or at-signs."""
+    if values.keys() != VALUE_PATTERNS.keys():
+        raise ValueError(
+            "Formula values must contain exactly the declared source fields"
+        )
+    for name, pattern in VALUE_PATTERNS.items():
+        if not re.fullmatch(pattern, values[name]):
+            raise ValueError(f"Invalid Formula value: {name}")
+    replacements = {**values, "BOTTLE_BLOCK": bottle_block}
+    unknown = set(TOKEN.findall(template)) - replacements.keys()
+    if unknown:
+        raise ValueError(f"Unknown Formula placeholders: {sorted(unknown)}")
+    rendered = TOKEN.sub(lambda match: replacements[match.group(1)], template)
+    if TOKEN.search(rendered):
+        raise ValueError("Unresolved Formula placeholder")
+    if len(re.findall(r"^\s*bottle\s+do\b", rendered, re.MULTILINE)) > 1:
+        raise ValueError("Duplicate bottle blocks")
+    return rendered
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--template", type=Path, required=True)
+    parser.add_argument("--repository", required=True)
+    parser.add_argument("--git-sha", required=True)
+    parser.add_argument("--version", required=True)
+    parser.add_argument("--source-sha256", required=True)
+    parser.add_argument("--bottle-block", type=Path)
+    parser.add_argument("--output", type=Path, required=True)
+    args = parser.parse_args()
+    values = {
+        "SOURCE_REPOSITORY": args.repository,
+        "GIT_SHA": args.git_sha,
+        "HOMEBREW_VERSION": args.version,
+        "SOURCE_SHA256": args.source_sha256,
+    }
+    bottle_block = (
+        args.bottle_block.read_text(encoding="utf-8") if args.bottle_block else ""
+    )
+    args.output.write_text(
+        render_formula(args.template.read_text(encoding="utf-8"), values, bottle_block),
+        encoding="utf-8",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/packaging/tests/test_render_homebrew_formula.py b/packaging/tests/test_render_homebrew_formula.py
new file mode 100644
index 000000000..0c0047aed
--- /dev/null
+++ b/packaging/tests/test_render_homebrew_formula.py
@@ -0,0 +1,177 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import importlib.util
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+MODULE_PATH = ROOT / "packaging/scripts/render_homebrew_formula.py"
+TEMPLATE_PATH = ROOT / "packaging/homebrew/tsfile-dev.rb.in"
+VALUES = {
+    "SOURCE_REPOSITORY": "ColinLeeo/tsfile",
+    "GIT_SHA": "abcdef1234567890abcdef1234567890abcdef12",
+    "HOMEBREW_VERSION": "2.5.0.dev0.20260910.123.1.gabcdef1",
+    "SOURCE_SHA256": "0123456789abcdef" * 4,
+}
+TEMPLATE = """class TsfileDev < Formula
+  url "https://github.com/@SOURCE_REPOSITORY@/archive/@GIT_SHA@.tar.gz"
+  version "@HOMEBREW_VERSION@"
+  sha256 "@SOURCE_SHA256@"
+@BOTTLE_BLOCK@
+end
+"""
+BOTTLE_BLOCK = """  bottle do
+    root_url "https://packages.apache.org/artifactory/tsfile/homebrew/dev/versions/2.5.0.dev0.20260910.123.1.gabcdef1/bottles"
+    sha256 cellar: :any, arm64_sequoia: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+    sha256 cellar: :any, sequoia: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+  end"""
+
+
+class RenderHomebrewFormulaTest(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.module = None
+        if MODULE_PATH.is_file():
+            spec = importlib.util.spec_from_file_location(
+                "render_homebrew_formula", MODULE_PATH
+            )
+            cls.module = importlib.util.module_from_spec(spec)
+            spec.loader.exec_module(cls.module)
+
+    def renderer(self):
+        self.assertIsNotNone(self.module, "Homebrew Formula renderer must exist")
+        return self.module.render_formula
+
+    def test_renders_exact_immutable_source_and_both_bottle_tags(self):
+        rendered = self.renderer()(TEMPLATE, VALUES, BOTTLE_BLOCK)
+        self.assertEqual(
+            rendered,
+            """class TsfileDev < Formula
+  url "https://github.com/ColinLeeo/tsfile/archive/abcdef1234567890abcdef1234567890abcdef12.tar.gz"
+  version "2.5.0.dev0.20260910.123.1.gabcdef1"
+  sha256 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+"""
+            + BOTTLE_BLOCK
+            + "\nend\n",
+        )
+
+    def test_pre_bottle_formula_has_no_bottle_block(self):
+        rendered = self.renderer()(TEMPLATE, VALUES)
+        self.assertNotIn("bottle do", rendered)
+        self.assertNotRegex(rendered, r"@[A-Z0-9_]+@")
+
+    def test_rejects_missing_and_undeclared_placeholders(self):
+        render = self.renderer()
+        with self.assertRaises(ValueError):
+            render(
+                TEMPLATE,
+                {key: value for key, value in VALUES.items() if key != "GIT_SHA"},
+            )
+        with self.assertRaises(ValueError):
+            render(TEMPLATE + "@UNKNOWN@", {**VALUES, "UNKNOWN": "anything"})
+
+    def test_rejects_template_tokens_in_bottle_block(self):
+        with self.assertRaises(ValueError):
+            self.renderer()(TEMPLATE, VALUES, BOTTLE_BLOCK.replace("sequoia", "@TAG@"))
+
+    def test_rejects_duplicate_bottle_blocks(self):
+        render = self.renderer()
+        for template, block in (
+            (TEMPLATE, BOTTLE_BLOCK + "\n" + BOTTLE_BLOCK),
+            (TEMPLATE + "\nbottle do\nend\n", BOTTLE_BLOCK),
+            (
+                TEMPLATE.replace("@BOTTLE_BLOCK@", "@BOTTLE_BLOCK@\n@BOTTLE_BLOCK@"),
+                BOTTLE_BLOCK,
+            ),
+        ):
+            with self.subTest(template=template, block=block):
+                with self.assertRaises(ValueError):
+                    render(template, VALUES, block)
+
+    def test_rejects_empty_or_non_immutable_source_identity(self):
+        render = self.renderer()
+        for key, value in (
+            ("SOURCE_SHA256", ""),
+            ("SOURCE_SHA256", "bad"),
+            ("GIT_SHA", ""),
+            ("GIT_SHA", "abcdef1"),
+            ("GIT_SHA", "develop"),
+            ("SOURCE_REPOSITORY", 'ColinLeeo/tsfile"'),
+            ("HOMEBREW_VERSION", 'bad"\nversion "injected'),
+        ):
+            with self.subTest(key=key, value=value):
+                with self.assertRaises(ValueError):
+                    render(TEMPLATE, {**VALUES, key: value})
+
+    def test_preserves_ruby_interpolation_and_other_at_signs(self):
+        extra = "\n# maintainer@example.org\n# #{rpath}\n"
+        self.assertTrue(self.renderer()(TEMPLATE + extra, VALUES).endswith(extra))
+
+    def test_cli_renders_real_template_with_optional_bottle_file(self):
+        self.renderer()
+        with tempfile.TemporaryDirectory() as temporary_directory:
+            directory = Path(temporary_directory)
+            output = directory / "tsfile-dev.rb"
+            block = directory / "bottle.rb"
+            block.write_text(BOTTLE_BLOCK, encoding="utf-8")
+            command = [
+                sys.executable,
+                str(MODULE_PATH),
+                "--template",
+                str(TEMPLATE_PATH),
+                "--repository",
+                VALUES["SOURCE_REPOSITORY"],
+                "--git-sha",
+                VALUES["GIT_SHA"],
+                "--version",
+                VALUES["HOMEBREW_VERSION"],
+                "--source-sha256",
+                VALUES["SOURCE_SHA256"],
+                "--output",
+                str(output),
+            ]
+            for bottle_args, expected_block in (
+                ([], ""),
+                (["--bottle-block", str(block)], BOTTLE_BLOCK),
+            ):
+                subprocess.run(command + bottle_args, check=True)
+                rendered = output.read_text(encoding="utf-8")
+                self.assertIn("class TsfileDev < Formula", rendered)
+                self.assertIn(
+                    'url "https://github.com/ColinLeeo/tsfile/archive/abcdef1234567890abcdef1234567890abcdef12.tar.gz"',
+                    rendered,
+                )
+                self.assertIn('version "2.5.0.dev0.20260910.123.1.gabcdef1"', rendered)
+                self.assertIn(
+                    'sha256 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"',
+                    rendered,
+                )
+                self.assertIn("#{rpath}", rendered)
+                self.assertNotRegex(rendered, r"@[A-Z0-9_]+@")
+                if expected_block:
+                    self.assertIn(BOTTLE_BLOCK, rendered)
+                else:
+                    self.assertNotIn("bottle do", rendered)
+
+
+if __name__ == "__main__":
+    unittest.main()