Publish GKG Ruby proto files as a gem (follow Gitaly pattern)
### Problem to solve The GKG proto file (`gkg.proto`) lives in the knowledge-graph repo at `crates/gkg-server/proto/gkg.proto`. The generated Ruby proto files (`gkg_pb.rb`, `gkg_services_pb.rb`) are currently hand-generated and committed directly into the Rails repo at `ee/lib/ai/knowledge_graph/proto/`. This breaks in a few ways: someone has to manually regenerate and copy the files whenever the proto changes, the two repos can silently drift apart, and the proto files define their own module structure (`Gkg::V1`) that doesn't match Rails autoloading conventions, so we need a Zeitwerk exclusion hack. Gitaly already solved this. It publishes proto definitions as the [`gitaly` RubyGem](https://rubygems.org/gems/gitaly), and Rails pulls them in through a normal Gemfile dependency. We should do the same thing for GKG. ### Current state **GKG repo (source of truth for proto)**: - Proto definition: [`crates/gkg-server/proto/gkg.proto`](https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/blob/main/crates/gkg-server/proto/gkg.proto) (package `gkg.v1`) - Generated Rust protos: `crates/gkg-server/src/proto/gkg.v1.rs` (generated and committed) - gRPC server implementation: `crates/gkg-server/src/grpc/` **Rails repo (consumer, on feature branch only)**: - Ruby proto files committed at `ee/lib/ai/knowledge_graph/proto/gkg_pb.rb` and `gkg_services_pb.rb` - Zeitwerk exclusion added to `config/initializers_before_autoloader/004_zeitwerk.rb` - Proto files loaded via `require_relative` from `grpc_client.rb` **Reference MR**: [gitlab-org/gitlab!221417](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/221417) ### How Gitaly does it The Gitaly proto gem pipeline works like this: 1. [`tools/protogem/build-proto-gem`](https://gitlab.com/gitlab-org/gitaly/-/blob/master/tools/protogem/build-proto-gem) is a Ruby script that runs `grpc_tools_ruby_protoc` against all `.proto` files and produces `*_pb.rb` (messages) and `*_services_pb.rb` (stubs). 2. The [`tools/protogem/Gemfile`](https://gitlab.com/gitlab-org/gitaly/-/blob/master/tools/protogem/Gemfile) declares `grpc-tools ~> 1.55.0` as the build dependency. 3. The [Gitaly Makefile](https://gitlab.com/gitlab-org/gitaly/-/blob/master/Makefile) has `build-proto-gem` (builds to `_build/gitaly.gem`) and `publish-proto-gem` (pushes to rubygems.org). 4. [`.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitaly/-/blob/master/.gitlab-ci.yml) has two jobs: - `build-proto-gem` -- manual, produces `_build/gitaly.gem` as CI artifact - `publish-proto-gem` -- runs automatically on tagged releases, calls `gem push` 5. The gem entry point (`gitaly.rb`) auto-requires all `*_services_pb.rb` files. The gemspec depends only on `grpc ~> 1.0`. Version comes from the repo's `VERSION` file. 6. Rails declares `gem 'gitaly', '~> 18.8.0'` in [`Gemfile:637`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/Gemfile) and loads everything with `require 'gitaly'` in [`lib/gitlab/gitaly_client.rb:5`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/gitaly_client.rb). No Zeitwerk exclusion needed because gem paths are outside the autoloader. 7. The [`stub_class` method](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/gitaly_client.rb) uses `Gitaly.const_get(name.to_s.camelcase.to_sym, false).const_get(:Stub, false)` to resolve service stubs at runtime instead of hardcoding them. ### Proposed solution Build a `gkg-proto` gem from this repo's CI pipeline and have Rails consume it through the Gemfile, same as Gitaly. ## Technical design ### 1. Proto gem build script Create `tools/protogem/build-proto-gem` in this repo, modeled on the [Gitaly script](https://gitlab.com/gitlab-org/gitaly/-/blob/master/tools/protogem/build-proto-gem): - Input: `crates/gkg-server/proto/gkg.proto` - Tool: `grpc_tools_ruby_protoc` (from `grpc-tools` gem) - Output: `_build/gkg-proto.gem` containing: - `ruby/proto/gkg/v1/gkg_pb.rb` (message definitions) - `ruby/proto/gkg/v1/gkg_services_pb.rb` (service stubs) - `ruby/proto/gkg.rb` (entry point, auto-requires service files) - `ruby/proto/gkg/version.rb` (version constant) ``` tools/ protogem/ build-proto-gem # Ruby build script Gemfile # grpc-tools dependency Gemfile.lock ``` The gem entry point (`gkg.rb`): ```ruby # Auto-generated by build-proto-gem require_relative 'gkg/version' require_relative 'gkg/v1/gkg_services_pb' ``` ### 2. Gemspec Dynamically generated during build: ```ruby Gem::Specification.new do |spec| spec.name = 'gkg-proto' spec.version = Gkg::VERSION spec.authors = ['GitLab Engineering'] spec.summary = 'Auto-generated gRPC client for the GitLab Knowledge Graph service' spec.files = Dir['ruby/proto/**/*.rb'] spec.require_paths = ['ruby/proto'] spec.add_dependency 'grpc', '~> 1.0' end ``` ### 3. Makefile targets ```makefile .PHONY: build-proto-gem build-proto-gem: ruby tools/protogem/build-proto-gem $(BUILD_GEM_OPTIONS) .PHONY: publish-proto-gem publish-proto-gem: build-proto-gem gem push _build/gkg-proto.gem ``` ### 4. CI pipeline jobs Add two jobs to `.gitlab-ci.yml`: ```yaml build-proto-gem: stage: publish image: ruby:${RUBY_VERSION} script: - make build-proto-gem BUILD_GEM_OPTIONS="--skip-verify-tag" artifacts: paths: - _build/gkg-proto.gem expire_in: 1 month rules: - when: manual allow_failure: true publish-proto-gem: stage: publish image: ruby:${RUBY_VERSION} script: - make publish-proto-gem rules: - if: $CI_COMMIT_TAG when: on_success ``` ### 5. Version management The gem version should track proto-breaking changes: - Patch bump: additive field changes, new RPCs - Minor bump: new services - Major bump: breaking changes (field removal, type changes) ### 6. Rails consumption (downstream changes) In `gitlab-org/gitlab`: 1. Add to `Gemfile`: ```ruby gem 'gkg-proto', '~> 0.1.0', feature_category: :knowledge_graph ``` 2. Remove committed proto files from `ee/lib/ai/knowledge_graph/proto/` 3. Remove the Zeitwerk exclusion from `config/initializers_before_autoloader/004_zeitwerk.rb` 4. Update `grpc_client.rb` to use gem-based loading: ```ruby require 'gkg' # loads all proto definitions from the gem ``` 5. Use dynamic stub resolution (following Gitaly): ```ruby def stub Gkg::V1::GkgService::Stub.new(endpoint, credentials, **channel_args) end ``` ### 7. Proto linting Add [`buf`](https://buf.build/) linting to CI to catch breaking changes before they ship: ```yaml lint-proto: stage: test image: bufbuild/buf:latest script: - buf lint crates/gkg-server/proto/ - buf breaking crates/gkg-server/proto/ --against '.git#branch=main' rules: - changes: - crates/gkg-server/proto/**/* ``` This requires a `buf.yaml` config file at the proto root. ## Comparison of approaches | Approach | Sync mechanism | Zeitwerk hack | Version control | Drift risk | Precedent | |----------|---------------|---------------|-----------------|------------|-----------| | Gem (proposed) | Bundler version pin | Not needed | Semver via gem | Low | Gitaly | | CI artifact download | Pipeline trigger | Still needed | Pipeline ID | Medium | None at GitLab | | Committed in Rails (current) | Manual copy | Required | None | High | None at GitLab | | Dedicated proto repo | Git submodule or gem | Depends | Git tags | Medium | buf.build pattern | The gem approach is the only one with existing precedent at GitLab. ## Implementation steps 1. Create `tools/protogem/` directory with build script and Gemfile 2. Add Makefile targets (`build-proto-gem`, `publish-proto-gem`) 3. Add CI jobs for building and publishing 4. Add `buf.yaml` and proto linting CI job 5. Publish initial `gkg-proto` gem version 6. Update Rails Gemfile to use the gem (separate MR in `gitlab-org/gitlab`) 7. Remove committed proto files and Zeitwerk exclusion from Rails (same MR) 8. Document the proto update workflow for contributors ## Key files | File | Purpose | |------|---------| | [`crates/gkg-server/proto/gkg.proto`](https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/blob/main/crates/gkg-server/proto/gkg.proto) | GKG proto source of truth | | [`tools/protogem/build-proto-gem` (Gitaly)](https://gitlab.com/gitlab-org/gitaly/-/blob/master/tools/protogem/build-proto-gem) | Reference build script | | [`tools/protogem/Gemfile` (Gitaly)](https://gitlab.com/gitlab-org/gitaly/-/blob/master/tools/protogem/Gemfile) | Reference build dependencies | | [Gitaly Makefile](https://gitlab.com/gitlab-org/gitaly/-/blob/master/Makefile) | Reference Makefile targets | | [Gitaly `.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitaly/-/blob/master/.gitlab-ci.yml) | Reference CI jobs | | [`lib/gitlab/gitaly_client.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/gitaly_client.rb) | Reference Rails consumption pattern | | [`gitlab-org/gitlab!221417`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/221417) | Current MR with committed proto files | ## Open questions 1. Gem name: `gkg-proto` vs `gkg` vs `gitlab-knowledge-graph-proto`? Gitaly's gem is just called `gitaly`, but GKG is a less distinctive name. 2. RubyGems.org vs internal gem server: Gitaly publishes to public rubygems.org. Should GKG do the same, or use the GitLab package registry? 3. Mono-gem vs separate: if additional proto files are added later (e.g., for Siphon integration), should they go in the same gem or a separate one?
issue