diff --git a/.github/workflows/_homebrew.yml b/.github/workflows/_homebrew.yml index 4d23fcb..3d147ff 100644 --- a/.github/workflows/_homebrew.yml +++ b/.github/workflows/_homebrew.yml @@ -120,16 +120,17 @@ jobs: path: homebrew-tap - name: Render formula + env: + FORMULA: ${{ steps.meta.outputs.formula }} + VERSION: ${{ steps.meta.outputs.version }} + SHA_DARWIN_AMD64: ${{ steps.checksums.outputs.darwin_amd64 }} + SHA_DARWIN_ARM64: ${{ steps.checksums.outputs.darwin_arm64 }} + SHA_LINUX_AMD64: ${{ steps.checksums.outputs.linux_amd64 }} + SHA_LINUX_ARM64: ${{ steps.checksums.outputs.linux_arm64 }} run: | set -eo pipefail mkdir -p homebrew-tap/Formula - sed -e 's/{{VERSION}}/${{ steps.meta.outputs.version }}/g' \ - -e 's/{{SHA_DARWIN_AMD64}}/${{ steps.checksums.outputs.darwin_amd64 }}/g' \ - -e 's/{{SHA_DARWIN_ARM64}}/${{ steps.checksums.outputs.darwin_arm64 }}/g' \ - -e 's/{{SHA_LINUX_AMD64}}/${{ steps.checksums.outputs.linux_amd64 }}/g' \ - -e 's/{{SHA_LINUX_ARM64}}/${{ steps.checksums.outputs.linux_arm64 }}/g' \ - "templates/${{ steps.meta.outputs.formula }}.rb" \ - > "homebrew-tap/Formula/${{ steps.meta.outputs.formula }}.rb" + ruby scripts/render_homebrew.rb "templates/${FORMULA}.rb" "homebrew-tap/Formula/${FORMULA}.rb" - name: Create PR on Homebrew tap id: create-pr diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..32d00df --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,34 @@ +name: Verify + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: verify-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + homebrew-templates: + name: Homebrew templates + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_ANALYTICS: "1" + steps: + - name: Checkout publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@7001732169813b7bd296f39014e60fcc07f0ef9a # main + + - name: Validate Homebrew templates + run: make test-homebrew diff --git a/Makefile b/Makefile index 844c5d6..37ff414 100644 --- a/Makefile +++ b/Makefile @@ -16,9 +16,14 @@ # # Runs focused regression tests for publisher scripts. .PHONY: test -test: +test: test-homebrew bash scripts/publish_rpm_reuse_test.sh +## Validate rendered Homebrew templates (requires Homebrew) +.PHONY: test-homebrew +test-homebrew: + brew ruby scripts/homebrew_template_test.rb + ## Format code and organize files # # Automatically formats all files in the project: diff --git a/scripts/homebrew_template_test.rb b/scripts/homebrew_template_test.rb new file mode 100644 index 0000000..4fc43e4 --- /dev/null +++ b/scripts/homebrew_template_test.rb @@ -0,0 +1,81 @@ +require 'tmpdir' +require 'open3' +require 'yaml' +require 'formula' +require 'formulary' +require 'simulate_system' +require_relative 'render_homebrew' + +def assert_equal(expected, actual) + raise "Expected #{expected.inspect}, got #{actual.inspect}" unless expected == actual +end + +def assert_rejected + yield +rescue ArgumentError + nil +else + raise 'Expected invalid template input to be rejected' +end + +root = File.expand_path('..', __dir__) +checksums = { + 'SHA_DARWIN_AMD64' => '1' * 64, + 'SHA_DARWIN_ARM64' => '2' * 64, + 'SHA_LINUX_AMD64' => '3' * 64, + 'SHA_LINUX_ARM64' => '4' * 64 +} + +Dir.mktmpdir('publisher-homebrew-test') do |directory| + Dir[File.join(root, 'packages/*/manifest.yaml')].each do |manifest_path| + manifest = YAML.safe_load_file(manifest_path) + name = manifest.fetch('homebrew_formula') + template_path = File.join(root, 'templates', "#{name}.rb") + template = File.read(template_path) + + %w[0.0.3 0.0.4 0.0.5].each do |version| + values = checksums.merge('VERSION' => version) + rendered = render_homebrew(template, values) + raise 'Unresolved template placeholder' if rendered.match?(/\{\{.*?\}\}/) + + output_path = File.join(directory, "#{name}-#{version}.rb") + output, status = Open3.capture2e(values, RbConfig.ruby, File.join(__dir__, 'render_homebrew.rb'), template_path, + output_path) + raise "Renderer CLI failed: #{output}" unless status.success? + + assert_equal(rendered, File.read(output_path)) + + manifest.fetch('platforms').each do |platform| + system, architecture = platform.split('-') + os = system == 'darwin' ? :macos : :linux + arch = architecture == 'arm64' ? :arm : :intel + Homebrew::SimulateSystem.with(os: os, arch: arch) do + path = Pathname(directory) / version / platform / "#{name}.rb" + formula = Formulary.from_contents(name, path, rendered, tap: Tap.fetch('techprimate/publisher')) + expected_revision = name == 'apple-docs' && version == '0.0.4' ? 1 : 0 + assert_equal(version, formula.version.to_s) + assert_equal(expected_revision, formula.revision) + assert_equal( + "https://packages.techprimate.com/#{manifest.fetch('package')}/bin/v#{version}/#{manifest.fetch('binary')}-#{platform}", + formula.stable.url + ) + assert_equal(checksums.fetch("SHA_#{platform.upcase.tr('-', '_')}"), formula.stable.checksum.to_s) + raise 'Missing formula test' unless formula.test_defined? + end + end + end + puts "PASS: #{name} rendering, platform selection, and version-scoped revisions" + end +end + +values = checksums.merge('VERSION' => '0.0.4') +assert_rejected { render_homebrew("version '{{UNKNOWN}}'", values) } +assert_rejected { render_homebrew("version '{{VERSION}}'", values.merge('VERSION' => "0.0.4'\nraise 'injected")) } +assert_rejected { render_homebrew("sha256 '{{SHA_DARWIN_ARM64}}'", values.merge('SHA_DARWIN_ARM64' => 'invalid')) } +assert_rejected do + render_homebrew("sha256 '{{SHA_DARWIN_ARM64}}'", values.reject do |key, _| + key == 'SHA_DARWIN_ARM64' + end) +end +assert_rejected { render_homebrew('class Broken < Formula', values) } +puts 'PASS: unknown placeholders, unsafe versions, missing/invalid checksums, and Ruby syntax errors are rejected' diff --git a/scripts/render_homebrew.rb b/scripts/render_homebrew.rb new file mode 100644 index 0000000..186935e --- /dev/null +++ b/scripts/render_homebrew.rb @@ -0,0 +1,29 @@ +require 'ripper' + +# Only substitute literal release metadata, never Ruby source supplied by a release. +def render_homebrew(template, values) + rendered = template.gsub(/\{\{(.*?)\}\}/) do + key = Regexp.last_match(1) + pattern = case key + when 'VERSION' + /\A[0-9]+(?:\.[0-9]+)*(?:[-+][0-9A-Za-z.-]+)?\z/ + when /\ASHA_(?:DARWIN|LINUX)_(?:AMD64|ARM64)\z/ + /\A[0-9a-fA-F]{64}\z/ + else + raise ArgumentError, "Unknown Homebrew template placeholder: #{key}" + end + value = values[key] + raise ArgumentError, "Missing or invalid Homebrew template value: #{key}" unless value && pattern.match?(value) + + value + end + raise ArgumentError, 'Rendered Homebrew formula has invalid Ruby syntax' unless Ripper.sexp(rendered) + + rendered +end + +if $PROGRAM_NAME == __FILE__ + template_path, output_path = ARGV + rendered = render_homebrew(File.read(template_path), ENV) + File.write(output_path, rendered) +end diff --git a/templates/apple-docs.rb b/templates/apple-docs.rb index 5f83f17..b0a222b 100644 --- a/templates/apple-docs.rb +++ b/templates/apple-docs.rb @@ -1,36 +1,47 @@ class AppleDocs < Formula - desc "CLI to explore Apple Developer Documentation" - homepage "https://github.com/techprimate/apple-docs-cli" - version "{{VERSION}}" + desc 'CLI to explore Apple Developer Documentation' + homepage 'https://github.com/techprimate/apple-docs-cli' + version '{{VERSION}}' + license 'FSL-1.1-MIT' + revision 1 if version.to_s == '0.0.4' on_macos do + depends_on macos: :ventura + on_arm do - url "https://packages.techprimate.com/apple-docs/bin/v{{VERSION}}/apple-docs-darwin-arm64" - sha256 "{{SHA_DARWIN_ARM64}}" + url 'https://packages.techprimate.com/apple-docs/bin/v{{VERSION}}/apple-docs-darwin-arm64' + sha256 '{{SHA_DARWIN_ARM64}}' end on_intel do - url "https://packages.techprimate.com/apple-docs/bin/v{{VERSION}}/apple-docs-darwin-amd64" - sha256 "{{SHA_DARWIN_AMD64}}" + url 'https://packages.techprimate.com/apple-docs/bin/v{{VERSION}}/apple-docs-darwin-amd64' + sha256 '{{SHA_DARWIN_AMD64}}' end end on_linux do on_arm do - url "https://packages.techprimate.com/apple-docs/bin/v{{VERSION}}/apple-docs-linux-arm64" - sha256 "{{SHA_LINUX_ARM64}}" + url 'https://packages.techprimate.com/apple-docs/bin/v{{VERSION}}/apple-docs-linux-arm64' + sha256 '{{SHA_LINUX_ARM64}}' end on_intel do - url "https://packages.techprimate.com/apple-docs/bin/v{{VERSION}}/apple-docs-linux-amd64" - sha256 "{{SHA_LINUX_AMD64}}" + url 'https://packages.techprimate.com/apple-docs/bin/v{{VERSION}}/apple-docs-linux-amd64' + sha256 '{{SHA_LINUX_AMD64}}' end end def install - binary = Dir["apple-docs-*"].first - bin.install binary => "apple-docs" + binary = Dir['apple-docs-*'].first + bin.install binary => 'apple-docs' + generate_completions_from_executable(bin / 'apple-docs', '--generate-completion-script') end test do + ENV['TELEMETRY_DISABLED'] = 'true' assert_match version.to_s, shell_output("#{bin}/apple-docs --version") + assert_match "apple-docs\t", shell_output("#{bin}/apple-docs agent skills list") + assert_match 'name: apple-docs', shell_output("#{bin}/apple-docs agent skills get apple-docs") + assert_path_exists bash_completion / 'apple-docs' + assert_path_exists zsh_completion / '_apple-docs' + assert_path_exists fish_completion / 'apple-docs.fish' end end