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) andproject(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.commitShaandcommitPath, 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.commitShaandcommitPathshare one authorization helper that gates on::Types::ProjectType.authorized?(project, context)and::Ability.allowed?(current_user, :read_code, project). - The commit fields reuse the
projectfield's load (same preloads, same batch key), soproject,commitSha, andcommitPathshare one batch. The load preloadsnamespace: [:route]for the path andproject_featurefor theread_codegate, 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 raiseActionController::UrlGenerationErrorand 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, oneprojects, and oneproject_featuresquery 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 assertsgraphql_errorsis nil so a null is distinguished from an error. - RuboCop clean; GraphQL docs regenerated and in sync; frontend graphql jest suite passes.
References
- Plan: monolith/S14 plan, step 11
- Spec: S14 version list
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
- 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('; ')}")- Confirm the output ends with
ALL PASS(all five checks). This script was run locally and printsALL 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)