AR GraphQL manifests connection (monolith/S14 step 7)
What does this MR do and why?
This MR adds the manifests keyset connection on the existing ArtifactRegistryImage GraphQL type (monolith/S14 Step 7). The ArtifactRegistryImage type and the ImagesResolver already exist on master; this MR mounts the new connection on that existing type.
It adds a new ArtifactRegistryManifest type with these fields:
iddigestmediaTypeartifactType(nullable)subjectDigest(nullable)size(BigInt, so a size above 2 GB does not overflow)createdAt(nullable)
The ManifestsResolver reads the repository, format, organization, and image ID off the artifact presenter and calls the Artifact Registry client #manifests through PaginatesLists, returning an externally-paginated connection whose pageInfo mirrors the Link-header cursors. A non-image repository format is guarded before the read, mirroring the images resolver.
The read leaves the client on its include_referrers=false default (referrer inclusion is a later step, Step 10). Because of that default, subjectDigest is always null on this connection today, and for a remote repository artifactType is also null.
size semantics:
- Hosted repository: the push-time manifest-tree total. An index total already contains its platform children, so it does not sum across sibling rows.
- Remote repository: the cached manifest's own payload bytes.
Fan-out bound
The manifests field carries FieldCallCount with limit: 20. The connection hangs off an image element, so a list of N images resolves it once per row. The limit of 20 matches the parent images connection's max_page_size, so a full page of 20 images still resolves the field for every row, while aliases cannot multiply the outbound fan-out past the row count. FieldCallCount counts total resolutions per operation (keyed on operation fingerprint and field), not per row. This corrects an earlier version that shipped with no budget.
Screenshots or screen recordings
N/A. No UI: the artifact_registry_ui feature flag is dark. Field descriptions are schema text, not i18n. No changelog because the feature is dark.
How to setup and validate locally
Run the script below with bundle exec rails runner <file> (or paste it into rails console). It stubs the Artifact Registry HTTP layer, sets up an organization and member, and asserts once-per-row fan-out, one request per row, correct field mapping, and the alias budget. It has been run and passes with output 1 OK / 2 OK / 3 OK / 4 OK / ALL OK.
require 'webmock'
require 'rspec/mocks/standalone'
include WebMock::API
WebMock.enable!
WebMock.disable_net_connect!(allow: ['gdk.test', '127.0.0.1', 'localhost'])
ActiveRecord::Base.transaction do
s = SecureRandom.hex(4)
org = FactoryBot.create(:organization, path: "arm-#{s}", name: "AR #{s}")
user = FactoryBot.create(:user, username: "arm-#{s}", email: "arm-#{s}@example.com")
FactoryBot.create(:organization_user, organization: org, user: user)
slug = Organizations::ArtifactRegistry::STUB_SLUG
base = 'http://artifact-registry.test'
Gitlab.config.artifact_registry['api_url'] = base
Feature.enable(:artifact_registry_ui, org)
allow_any_instance_of(ArtifactRegistry::TokenExchange).to receive(:token_for).and_return('tok')
repo_name = 'container-images'
image_id = 'e5f6a7b8-0000-0000-0000-000000000000'
other_id = 'f6a7b8c9-0000-0000-0000-000000000000'
repo_url = "#{base}/api/v1/#{slug}/repositories/#{repo_name}"
images_url = "#{repo_url}/docker/images"
man_url = "#{images_url}/#{image_id}/manifests"
other_url = "#{images_url}/#{other_id}/manifests"
jh = { 'Content-Type' => 'application/json' }
stub_request(:get, repo_url).to_return(status: 200, headers: jh,
body: { 'id' => 'r1', 'name' => repo_name, 'format' => 'docker', 'kind' => 'hosted',
'visibility' => 'private', 'downloads_count' => 0, 'size_bytes' => 0, 'settings' => {} }.to_json)
stub_request(:get, images_url).with(query: { limit: '20' }).to_return(status: 200, headers: jh,
body: [{ 'id' => image_id, 'name' => 'api-gateway' }, { 'id' => other_id, 'name' => 'payment-core' }].to_json)
manifest = { 'id' => 'm1', 'digest' => 'sha256:aaaa',
'media_type' => 'application/vnd.oci.image.manifest.v1+json',
'artifact_type' => nil, 'subject_digest' => nil, 'size' => 2048,
'created_at' => '2026-07-03T09:15:00Z' }
stub_request(:get, man_url).with(query: { limit: '20', include_referrers: 'false' })
.to_return(status: 200, headers: jh, body: [manifest].to_json)
stub_request(:get, other_url).with(query: { limit: '20', include_referrers: 'false' })
.to_return(status: 200, headers: jh, body: [manifest.merge('id' => 'm2', 'digest' => 'sha256:cccc')].to_json)
query = <<~GQL
query($id: OrganizationsOrganizationID!, $name: String!) {
organization(id: $id) {
artifactRegistryRepository(name: $name) {
images(first: 20) {
nodes {
id
manifests(first: 20) {
nodes { id digest mediaType artifactType subjectDigest size createdAt }
pageInfo { hasNextPage hasPreviousPage }
}
}
}
}
}
}
GQL
vars = { 'id' => org.to_global_id.to_s, 'name' => repo_name }
res = GitlabSchema.execute(query, context: { current_user: user }, variables: vars)
raise "errors: #{res['errors']}" if res['errors']
image_nodes = res.dig('data', 'organization', 'artifactRegistryRepository', 'images', 'nodes')
ids = image_nodes.map { |n| n.dig('manifests', 'nodes').map { |m| m['id'] } }
raise "1 FAIL: fan-out #{ids.inspect}" unless ids == [['m1'], ['m2']]
puts '1 OK: an image list resolves manifests once per row, distinct results per image'
assert_requested(:get, man_url, query: { limit: '20', include_referrers: 'false' }, times: 1)
assert_requested(:get, other_url, query: { limit: '20', include_referrers: 'false' }, times: 1)
puts '2 OK: one manifests request per image row, no per-row multiplication'
m = image_nodes.first.dig('manifests', 'nodes').first
raise '3 FAIL: field mapping' unless m['digest'] == 'sha256:aaaa' && m['size'] == '2048' &&
m['subjectDigest'].nil? && m['artifactType'].nil?
puts '3 OK: manifest fields map, size is BigInt string, referrer fields null on the default read'
alias_query = <<~GQL
query($id: OrganizationsOrganizationID!, $name: String!) {
organization(id: $id) {
artifactRegistryRepository(name: $name) {
images(first: 20) {
nodes {
id
#{(1..21).map { |n| "a#{n}: manifests(first: 1) { nodes { id } }" }.join("\n")}
}
}
}
}
}
GQL
stub_request(:get, man_url).with(query: { limit: '1', include_referrers: 'false' })
.to_return(status: 200, headers: jh, body: [manifest].to_json)
stub_request(:get, other_url).with(query: { limit: '1', include_referrers: 'false' })
.to_return(status: 200, headers: jh, body: [manifest].to_json)
alias_res = GitlabSchema.execute(alias_query, context: { current_user: user }, variables: vars)
msgs = (alias_res['errors'] || []).map { |e| e['message'] }.join(' ')
raise "4 FAIL: no budget error, got #{msgs.inspect}" unless msgs.include?('can be requested only for 20')
puts '4 OK: aliasing the field past 20 per operation is rejected by the call-count budget'
raise ActiveRecord::Rollback
end
WebMock.disable!
puts 'ALL OK'Automated tests cover the same ground:
ArtifactRegistryManifesttype spec: field list, types, nullability, BigInt size.- Manifests resolver spec: limit-20 budget, format guard, page-size cap below and above the max, forward and backward cursors, no
include_referrers, null-on-missing. - Image-type field-list spec.
- End-to-end request spec reaching
manifeststhroughimages(first: 20): two image rows each resolving the field once (one request per row), per-format listing, empty connection rendering[], theinclude_referrers=falsedefault, pageInfo cursors, page-size cap, silent 401/403/404, 5xx top-level error, transport failure and timeout, alias-per-row within budget, aliasing past the budget rejected, and non-member and anonymous callers hidden.
All specs pass locally; RuboCop clean; GraphQL schema artifacts regenerated.
MR acceptance checklist
Please refer to the project's MR acceptance checklist when reviewing this change.
Dependencies (branch stacking)
Stacked on:
- Step 5 (single-artifact fields plus
ArtifactRegistryImage): !251068 (merged) - Step 3 (client manifest read): !251046 (merged)
These transitively carry Steps 1 and 4. Until they merge, this MR's diff also contains their commits.
Review/merge order: 1, then 3, then 4, then 5, then 7.