AR manifests sort argument (monolith/S14 step 9)

What does this MR do and why?

This MR adds a sort argument to the manifests connection on the Artifact Registry ArtifactRegistryImage GraphQL type (monolith/S14 Step 9). It introduces a new enum ArtifactRegistryManifestSort with two values, CREATED_AT_ASC and CREATED_AT_DESC (publication date ascending and descending). Publication date is the only column the manifests endpoint sorts by.

Caveat: on a remote (cached) repository, created_at is the cache-fill time, so ordering reflects cache recency rather than upstream publish order.

ManifestsResolver declares the argument as optional with a publication-date-descending default (CREATED_AT_DESC) and replace_null_with_default: true, so an explicit sort: null folds to the default instead of reaching the resolver as nil. The resolver splits the enum's value into the client's sort and order params and sends the pair unconditionally, so the order stays pinned even if Artifact Registry's own default ever moves. The manifests field description now reads "ordered by publication date descending by default"; the previous wording stated an unconditional order that the new argument makes inaccurate.

No breaking change: a new optional argument with a default, nothing removed, renamed, or retyped. It ships under experiment: { milestone: '19.4' } behind the dark artifact_registry_ui feature flag, so no changelog. The enum and argument text is schema text, not i18n.

Tests cover the enum value map, the resolver's static configuration (argument type, default derived from the enum, replace_null_with_default?), and request specs for the omitted default, all enum values forwarded, sort: null fallback, and a non-default sort travelling with a continuation cursor. RuboCop is clean; GraphQL docs and introspection are regenerated and in sync.

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 and asserts the sort argument reaches Artifact Registry, including that an explicit sort: null folds back to the default rather than raising. It has been run locally and prints 1 OK / 2 OK / 3 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)
  mapping = FactoryBot.create(:artifact_registry_namespace_mapping, organization: org)

  base = 'http://artifact-registry.test'
  slug = 'resolved-handle'
  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')
  allow_any_instance_of(ArtifactRegistry::ServiceCredential).to receive(:token).and_return('svc')

  jh = { 'Content-Type' => 'application/json' }
  repo_name = 'container-images'
  image_id  = 'e5f6a7b8-0000-0000-0000-000000000000'
  ns_url    = "#{base}/api/gitlab/v1/namespaces/#{mapping.ar_namespace_id}"
  repo_url  = "#{base}/api/v1/#{slug}/repositories/#{repo_name}"
  images_url = "#{repo_url}/docker/images"
  man_url   = "#{images_url}/#{image_id}/manifests"

  stub_request(:get, ns_url).to_return(status: 200, headers: jh,
    body: { 'id' => mapping.ar_namespace_id, 'slug' => slug, 'status' => 'active' }.to_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' }].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' }

  def stub_manifests(url, order, jh, manifest)
    stub_request(:get, url)
      .with(query: { limit: '20', sort: 'created_at', order: order, include_referrers: 'false' })
      .to_return(status: 200, headers: jh, body: [manifest].to_json)
  end

  query = <<~GQL
    query($id: OrganizationsOrganizationID!, $name: String!, $sort: ArtifactRegistryManifestSort) {
      organization(id: $id) {
        artifactRegistryRepository(name: $name) {
          images(first: 20) {
            nodes { id manifests(first: 20, sort: $sort) { nodes { id } } }
          }
        }
      }
    }
  GQL
  run = ->(sort) do
    GitlabSchema.execute(query, context: { current_user: user },
      variables: { 'id' => org.to_global_id.to_s, 'name' => repo_name, 'sort' => sort })
  end

  desc_stub = stub_manifests(man_url, 'desc', jh, manifest)
  r = run.call(nil)
  raise "1 FAIL: #{r['errors']}" if r['errors']
  assert_requested(:get, man_url, query: hash_including('sort' => 'created_at', 'order' => 'desc'), times: 1)
  puts '1 OK: omitted sort sends the created_at/desc default to Artifact Registry'

  WebMock.reset_executed_requests!
  asc_stub = stub_manifests(man_url, 'asc', jh, manifest)
  r = run.call('CREATED_AT_ASC')
  raise "2 FAIL: #{r['errors']}" if r['errors']
  assert_requested(:get, man_url, query: hash_including('sort' => 'created_at', 'order' => 'asc'), times: 1)
  puts '2 OK: explicit CREATED_AT_ASC sends created_at/asc'

  WebMock.reset_executed_requests!
  r = run.call(nil)
  raise "3 FAIL: #{r['errors']}" if r['errors']
  assert_requested(:get, man_url, query: hash_including('sort' => 'created_at', 'order' => 'desc'), times: 1)
  puts '3 OK: explicit sort: null falls back to created_at/desc rather than raising on **nil'

  raise ActiveRecord::Rollback
end
WebMock.disable!
puts 'ALL OK'

The script needs a database schema matching this branch; run bundle exec rails db:migrate first if your local dev database is behind.

Screenshots or screen recordings

N/A. No UI (feature flag dark; schema text only).

Merge request checklist

Reviewers, please refer to the project's merge request checklist to confirm this change meets contribution standards.

Edited by Narendran

Merge request reports

Loading
Loading