AR version publish attribution join (monolith/S14 plan: 11/25)

What does this MR do and why?

This MR resolves the publish attribution on ArtifactRegistryVersion from the opaque references Artifact Registry stores. It is step 11 of the 25-step monolith/S14 plan, behind the dark artifact_registry_ui feature flag.

The version type gains four fields:

  • createdBy (UserType) and project (ProjectType), each batch-loaded from the reference AR stored, resolving null when the reference is absent, no longer exists, or the viewer cannot see the record.
  • commitSha and commitPath, the raw SHA AR stored and a web path to that commit within the resolved project.

Key decisions:

  • Both commit fields identify repository content, so each is null unless the viewer can read the project code (read_code), not merely see the project. A SHA or its path would otherwise leak a commit identifier of a repository the viewer cannot access. commitSha and commitPath share one authorization helper that gates on ::Types::ProjectType.authorized?(project, context) and ::Ability.allowed?(current_user, :read_code, project).
  • The commit fields reuse the project field's load (same preloads, same batch key), so project, commitSha, and commitPath share one batch. The load preloads namespace: [:route] for the path and project_feature for the read_code gate, so neither adds a per-project query.
  • The helper guards the SHA against the commit route's \h{7,64} format before loading the project. AR types the SHA as an opaque string, so an empty or non-hex value is representable; unguarded it would raise ActionController::UrlGenerationError and 500 the whole query. A malformed SHA renders null on both commit fields, never an error.

Testing:

  • Type spec: field shapes only (resolver behavior is covered by the request spec, not unit-tested against the type).
  • Request spec: the join over maven and npm; a batching case with distinct references per row asserting one users, one projects, and one project_features query for the whole page and no Gitaly call; the null/unresolved/unauthorized outcomes for each of creator, project, and both commit fields; a "viewer can see the project but cannot read its code" case proving both commit fields null; tables of malformed, route-boundary, and non-string SHAs; and a non-numeric creator reference. Each case asserts graphql_errors is nil so a null is distinguished from an error.
  • RuboCop clean; GraphQL docs regenerated and in sync; frontend graphql jest suite passes.

References

Screenshots or screen recordings

N/A. Backend GraphQL schema change behind the dark artifact_registry_ui flag; no UI in this MR.

How to set up and validate locally

  1. Save the following script and run it with bundle exec rails runner validate_step11.rb (needs Gitaly running, as any project factory does):
# Validates the version publish-attribution join: createdBy/project/commitSha/commitPath resolve
# from the opaque references, and commitPath is null (not a 500) for a malformed SHA.
require 'webmock'
require 'rspec/mocks/standalone'
include WebMock::API
WebMock.enable!
WebMock.disable_net_connect!(allow: %w[gdk.test 127.0.0.1 localhost])

failures = []
check = ->(desc, cond) { puts("#{cond ? 'PASS' : 'FAIL'}: #{desc}"); failures << desc unless cond }

ActiveRecord::Base.transaction do
  s = SecureRandom.hex(4)
  org  = FactoryBot.create(:organization, path: "arv-#{s}", name: "AR #{s}")
  user = FactoryBot.create(:user)
  FactoryBot.create(:organization_user, organization: org, user: user)
  creator = FactoryBot.create(:user)
  project = FactoryBot.create(:project, :public)

  base = 'http://artifact-registry.test'
  slug = 'resolved-handle'
  repo = 'maven-releases'
  pkg  = 'a1b2c3d4'
  good_sha = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
  Gitlab.config.artifact_registry['api_url'] = base
  Feature.enable(:artifact_registry_ui, org)

  registry = ArtifactRegistry::NamespaceMapping::Registry.new(slug: slug, status: 'active')
  FactoryBot.create(:artifact_registry_namespace_mapping, organization: org)
  allow_any_instance_of(ArtifactRegistry::NamespaceMapping).to receive(:registry).and_return(registry)
  allow_any_instance_of(ArtifactRegistry::TokenExchange).to receive(:token_for).and_return('tok')

  repo_url = "#{base}/api/v1/#{slug}/repositories/#{repo}"
  pkgs_url = "#{repo_url}/maven/packages"
  vers_url = "#{pkgs_url}/#{pkg}/versions"
  jh = { 'Content-Type' => 'application/json' }

  stub_request(:get, repo_url).to_return(status: 200, headers: jh,
    body: { 'id' => 'r1', 'name' => repo, 'format' => 'maven', 'kind' => 'hosted',
            'visibility' => 'private', 'downloads_count' => 0, 'size_bytes' => 0, 'settings' => {} }.to_json)
  stub_request(:get, pkgs_url).with(query: hash_including({})).to_return(status: 200, headers: jh,
    body: [{ 'id' => pkg, 'group_id' => 'com.example', 'artifact_id' => 'core' }].to_json)

  query = <<~GQL
    query($id: OrganizationsOrganizationID!, $name: String!) {
      organization(id: $id) {
        artifactRegistryRepository(name: $name) {
          packages(first: 20) {
            nodes { ... on ArtifactRegistryMavenPackage {
              versions(first: 20) { nodes { id createdBy { id } project { fullPath } commitSha commitPath } }
            } }
          }
        }
      }
    }
  GQL

  node_for = ->(git_sha) do
    row = { 'id' => 'v1', 'version' => '1.0.0', 'created_at' => '2026-07-03T09:15:00Z',
            'created_by' => creator.id.to_s, 'project_id' => project.id.to_s, 'git_commit_sha' => git_sha }
    stub_request(:get, vers_url).with(query: hash_including({})).to_return(status: 200, headers: jh, body: [row].to_json)
    res = GitlabSchema.execute(query, context: { current_user: user },
      variables: { 'id' => org.to_global_id.to_s, 'name' => repo })
    raise "errors: #{res['errors']}" if res['errors']
    res.dig('data', 'organization', 'artifactRegistryRepository', 'packages', 'nodes', 0, 'versions', 'nodes', 0)
  end

  n = node_for.call(good_sha)
  check.call('createdBy resolves to the referenced user', n.dig('createdBy', 'id') == creator.to_global_id.to_s)
  check.call('project resolves to the referenced project', n.dig('project', 'fullPath') == project.full_path)
  check.call('commitSha renders the raw SHA when the viewer can read the code', n['commitSha'] == good_sha)
  check.call('commitPath is the project-derived path',
    n['commitPath'] == Gitlab::Routing.url_helpers.project_commit_path(project, good_sha))

  m = node_for.call('nothex')
  check.call('malformed SHA renders null on both commit fields (no 500)',
    m['commitSha'].nil? && m['commitPath'].nil?)

  raise ActiveRecord::Rollback
end
WebMock.disable!
puts(failures.empty? ? "\nALL PASS" : "\nFAILURES: #{failures.join('; ')}")
  1. Confirm the output ends with ALL PASS (all five checks). This script was run locally and prints ALL PASS.

Database review

This MR adds no migration and no new query method. The attribution lookups are primary-key IN batches issued through BatchLoader (BatchModelLoader), one per referenced type across a version page. The projects batch preloads namespace: [:route] for the commit-path helper and project_feature for the read_code gate, so the page issues three batched queries total:

SELECT "users".* FROM "users" WHERE "users"."id" IN (<distinct creator ids on the page>);
SELECT "projects".* FROM "projects" WHERE "projects"."id" IN (<distinct project ids on the page>);
SELECT "project_features".* FROM "project_features" WHERE "project_features"."project_id" IN (<distinct project ids on the page>);

All three are indexed lookups bounded by the page size (first:, default/max 20), so each is a single index scan over at most 20 ids. No repository is read: the commit object is never loaded and commitPath is a pure route helper. The request spec asserts exactly one users, one projects, and one project_features query for a full page. Assigning a database reviewer to confirm the batch shape.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

Feature flag artifact_registry_ui is dark; no changelog (dark), and the field descriptions are schema text rather than i18n. Related to #618410 (closed)

Edited by Narendran

Merge request reports

Loading
Loading