diff --git a/.github/scripts/tests/test_release_artifacts.py b/.github/scripts/tests/test_release_artifacts.py index e88cc138..15740a7c 100644 --- a/.github/scripts/tests/test_release_artifacts.py +++ b/.github/scripts/tests/test_release_artifacts.py @@ -2580,6 +2580,60 @@ def render_release_template( return sc_compose.compose_file(request).rendered_text +def assert_homebrew_formula_install_executes(formula: str) -> None: + """Parse and execute a rendered formula against the Homebrew path helper shape.""" + ruby = subprocess.run( + ["ruby", "-c"], input=formula, text=True, capture_output=True, check=False + ) + assert ruby.returncode == 0, ruby.stderr + + harness = """ +class InstallPath + def /(component) + self + end + + def install(paths) + end +end + +class Formula + def self.test(&block) + end + + def self.method_missing(name, *args, &block) + class_eval(&block) if block + end + + def bin + InstallPath.new + end + + def pkgshare + InstallPath.new + end + + def shell_output(*args) + "" + end + + def assert_match(*args) + end +end + +eval(STDIN.read, TOPLEVEL_BINDING) +ScCompose.new.install +""" + execution = subprocess.run( + ["ruby", "-e", harness], + input=formula, + text=True, + capture_output=True, + check=False, + ) + assert execution.returncode == 0, execution.stderr + + def test_release_channel_templates_render_to_valid_ruby_and_json(tmp_path: Path) -> None: formula = render_release_template( tmp_path, @@ -2602,20 +2656,17 @@ def test_release_channel_templates_render_to_valid_ruby_and_json(tmp_path: Path) "binary_paths": ["bin/sc-compose", "bin/sc-compose-daemon"], "bundled_paths": [ { - "destination_components": ["pkgshare", "examples"], + "destination_components": ["pkgshare", "examples", "nested"], "source_glob": "share/sc-compose/examples/*", } ], }, ) - ruby = subprocess.run( - ["ruby", "-c"], input=formula, text=True, capture_output=True, check=False - ) - assert ruby.returncode == 0, ruby.stderr + assert_homebrew_formula_install_executes(formula) assert 'bin.install "bin/sc-compose"' in formula assert 'bin.install "bin/sc-compose-daemon"' in formula assert 'shell_output("#{bin}/" + "sc-compose-daemon"' in formula - assert '("pkgshare"/"examples").install Dir["share/sc-compose/examples/*"]' in formula + assert '(pkgshare/"examples"/"nested").install' in formula scoop = render_release_template( tmp_path, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2faa145b..b15bfd1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,29 @@ jobs: python3 -m pytest .github/scripts/tests python3 -m pytest plugins/sc-publish/.github/scripts/tests + - name: Verify vendored Homebrew template tracks sc-publish + shell: bash + run: | + set -euo pipefail + # PR #85 contains this already-validated template fix. Until it + # merges, accept only its exact template as the authorized ahead-of- + # upstream state; after merge, the develop comparison wins again. + git fetch --depth=1 https://github.com/randlee/sc-publish.git \ + refs/heads/develop:refs/remotes/sc-publish-upstream/develop + git fetch --depth=1 https://github.com/randlee/sc-publish.git \ + refs/pull/85/head:refs/remotes/sc-publish-upstream/pr-85 + template=plugins/sc-publish/release/homebrew/formula.rb.j2 + if git diff --quiet sc-publish-upstream/develop -- "${template}"; then + exit 0 + fi + if git diff --quiet sc-publish-upstream/pr-85 -- "${template}"; then + echo "vendored Homebrew template matches the authorized sc-publish PR #85 fix" + exit 0 + fi + echo "vendored Homebrew template diverges from both sc-publish develop and PR #85" >&2 + git diff sc-publish-upstream/develop -- "${template}" >&2 + exit 1 + - name: Validate publish-order dependency graph run: | python3 .github/scripts/release_artifacts.py validate-publish-order \ diff --git a/crates/sc-compose/tests/cli/render.rs b/crates/sc-compose/tests/cli/render.rs index 636150da..4470c3fb 100644 --- a/crates/sc-compose/tests/cli/render.rs +++ b/crates/sc-compose/tests/cli/render.rs @@ -130,7 +130,7 @@ fn render_homebrew_formula_escapes_manifest_values_as_ruby_strings() { "linux_sha256": "linux-hash", "binary_paths": ["bin/sc-compose"], "bundled_paths": [{ - "destination_components": ["share", "quoted\"component", "examples"], + "destination_components": ["pkgshare", "quoted\"component", "examples"], "source_glob": "share/quoted\"component/examples/*" }], "binary": "sc-compose", @@ -163,7 +163,10 @@ fn render_homebrew_formula_escapes_manifest_values_as_ruby_strings() { assert!(formula.contains("desc \"A \\\"quoted\\\" description\"")); assert!(formula.contains("assert_match \"A \\\"quoted\\\" result\"")); assert!(formula.contains("bin.install \"bin/sc-compose\"")); - assert!(formula.contains("(\"share\"/\"quoted\\\"component\"/\"examples\").install Dir[\"share/quoted\\\"component/examples/*\"]")); + // Windows-latest currently includes Ruby, but this cross-platform Rust + // suite does not declare it as a toolchain dependency. The top-level + // Python regression owns execution of this multi-component expression. + assert!(formula.contains("(pkgshare/\"quoted\\\"component\"/\"examples\").install Dir[\"share/quoted\\\"component/examples/*\"]")); assert!(!formula.contains("{{")); } diff --git a/docs/sprints/fix-homebrew-formula-bundled-paths-tojson.md b/docs/sprints/fix-homebrew-formula-bundled-paths-tojson.md new file mode 100644 index 00000000..b7a1f8fd --- /dev/null +++ b/docs/sprints/fix-homebrew-formula-bundled-paths-tojson.md @@ -0,0 +1,62 @@ +--- +status: pending-upstream +branch: fix/homebrew-formula-bundled-paths-tojson +pr: 603 +upstream_pr: https://github.com/randlee/sc-publish/pull/85 +--- + +# Fix: render Homebrew bundled-path helpers as Ruby methods + +## Scope + +Render the first `bundled_paths.destination_components` entry as a Homebrew +path helper and quote only later path segments. This produces, for example, +`(pkgshare/"examples").install ...`, not `("pkgshare"/"examples").install ...`. + +## Closeout + +- **Load-bearing template:** `release/homebrew/formula.rb.j2`. The release + manifest selects that exact path at + `release/publish-artifacts.toml` → `channels.homebrew.formulas[0].template`; + `.github/workflows/homebrew-publish.yml` reads the manifest and passes the + selected template to the published renderer. +- **Vendored copy:** `plugins/sc-publish/release/homebrew/formula.rb.j2` is a + consumer-local mirror and must not be hand-edited. It temporarily matches the + validated fix in [sc-publish PR #85](https://github.com/randlee/sc-publish/pull/85) + at `8f858ed`, while upstream `develop` remains unfixed. CI fetches both + upstream `develop` and PR #85: it accepts byte parity with either source and + rejects every other divergence. Once PR #85 merges, `develop` becomes the + required byte-parity source automatically. +- **Prior test gap:** the earlier regression only asserted that the rendered + text contained `("pkgshare"/"examples").install ...` and ran `ruby -c`. + Quoted and bare helper forms are both valid Ruby syntax, so neither check + exercised Homebrew's runtime path-helper API. The regression now executes + a multi-component rendered `install` block against a minimal + Homebrew-compatible helper harness; the old quoted receiver fails because a + Ruby `String` has no `install` method. The Rust CLI test keeps its + cross-platform rendering assertion only: GitHub's `windows-latest` image + currently includes Ruby, but the Rust test does not declare Ruby as a + toolchain dependency. Ruby execution is therefore intentionally owned by + this Ubuntu Python regression rather than a runner-provided executable. +- **Validation evidence (QA6 correction after `ed6471c`):** both the + load-bearing top-level `.github/scripts/tests` suite and the vendored + `plugins/sc-publish/.github/scripts/tests` suite pass genuinely with the + fixed template; no xfail or skip is used. The CI guard permits this single, + tracked, exact PR #85 divergence while that PR is open, while still failing + any divergence from both upstream sources. + +## QA7 coverage and follow-up + +At `d942f7b`, the CI guard began accepting only byte parity with upstream +`develop` or the exact [sc-publish PR #85](https://github.com/randlee/sc-publish/pull/85) +head; every other vendored divergence fails. The vendored three-component +fixture was added later, at `c48b046`. + +The top-level and vendored Ruby-execution fixtures both use +`["pkgshare", "examples", "nested"]`. They therefore execute the bare helper +and the subsequently JSON-quoted path components, and fail against the +pre-fix template. QA7 re-runs both focused tests as genuine passes. + +- **Tracked follow-up:** when PR #85 merges upstream and this repository + synchronizes its vendored copy, remove the PR #85 fetch-and-diff branch from + `.github/workflows/ci.yml` and require `sc-publish/develop` byte parity only. diff --git a/plugins/sc-publish/.github/scripts/tests/test_release_artifacts.py b/plugins/sc-publish/.github/scripts/tests/test_release_artifacts.py index 5b87fd3e..17cd549c 100644 --- a/plugins/sc-publish/.github/scripts/tests/test_release_artifacts.py +++ b/plugins/sc-publish/.github/scripts/tests/test_release_artifacts.py @@ -2459,6 +2459,60 @@ def render_release_template( return sc_compose.compose_file(request).rendered_text +def assert_homebrew_formula_install_executes(formula: str) -> None: + """Parse and execute a rendered formula against the Homebrew path helper shape.""" + ruby = subprocess.run( + ["ruby", "-c"], input=formula, text=True, capture_output=True, check=False + ) + assert ruby.returncode == 0, ruby.stderr + + harness = """ +class InstallPath + def /(component) + self + end + + def install(paths) + end +end + +class Formula + def self.test(&block) + end + + def self.method_missing(name, *args, &block) + class_eval(&block) if block + end + + def bin + InstallPath.new + end + + def pkgshare + InstallPath.new + end + + def shell_output(*args) + "" + end + + def assert_match(*args) + end +end + +eval(STDIN.read, TOPLEVEL_BINDING) +ScCompose.new.install +""" + execution = subprocess.run( + ["ruby", "-e", harness], + input=formula, + text=True, + capture_output=True, + check=False, + ) + assert execution.returncode == 0, execution.stderr + + def test_release_channel_templates_render_to_valid_ruby_and_json(tmp_path: Path) -> None: formula = render_release_template( tmp_path, @@ -2481,20 +2535,17 @@ def test_release_channel_templates_render_to_valid_ruby_and_json(tmp_path: Path) "binary_paths": ["bin/sc-compose", "bin/sc-compose-daemon"], "bundled_paths": [ { - "destination_components": ["pkgshare", "examples"], + "destination_components": ["pkgshare", "examples", "nested"], "source_glob": "share/sc-compose/examples/*", } ], }, ) - ruby = subprocess.run( - ["ruby", "-c"], input=formula, text=True, capture_output=True, check=False - ) - assert ruby.returncode == 0, ruby.stderr + assert_homebrew_formula_install_executes(formula) assert 'bin.install "bin/sc-compose"' in formula assert 'bin.install "bin/sc-compose-daemon"' in formula assert 'shell_output("#{bin}/" + "sc-compose-daemon"' in formula - assert '("pkgshare"/"examples").install Dir["share/sc-compose/examples/*"]' in formula + assert '(pkgshare/"examples"/"nested").install' in formula scoop = render_release_template( tmp_path, diff --git a/plugins/sc-publish/release/homebrew/formula.rb.j2 b/plugins/sc-publish/release/homebrew/formula.rb.j2 index ebbd17e0..e731fea4 100644 --- a/plugins/sc-publish/release/homebrew/formula.rb.j2 +++ b/plugins/sc-publish/release/homebrew/formula.rb.j2 @@ -26,7 +26,7 @@ class {{ formula_class }} < Formula bin.install {{ binary_path | tojson }} {% endfor %} {% for bundle in bundled_paths %} - ({% for component in bundle.destination_components %}{{ component | tojson }}{% if not loop.last %}/{% endif %}{% endfor %}).install Dir[{{ bundle.source_glob | tojson }}] + ({% for component in bundle.destination_components %}{% if loop.first %}{{ component }}{% else %}/{{ component | tojson }}{% endif %}{% endfor %}).install Dir[{{ bundle.source_glob | tojson }}] {% endfor %} end diff --git a/release/homebrew/formula.rb.j2 b/release/homebrew/formula.rb.j2 index ebbd17e0..e731fea4 100644 --- a/release/homebrew/formula.rb.j2 +++ b/release/homebrew/formula.rb.j2 @@ -26,7 +26,7 @@ class {{ formula_class }} < Formula bin.install {{ binary_path | tojson }} {% endfor %} {% for bundle in bundled_paths %} - ({% for component in bundle.destination_components %}{{ component | tojson }}{% if not loop.last %}/{% endif %}{% endfor %}).install Dir[{{ bundle.source_glob | tojson }}] + ({% for component in bundle.destination_components %}{% if loop.first %}{{ component }}{% else %}/{{ component | tojson }}{% endif %}{% endfor %}).install Dir[{{ bundle.source_glob | tojson }}] {% endfor %} end