Hamburger Cross Icon
GitLab Cataloger - Lunar Cataloger

GitLab Cataloger

⇄ Cataloger Beta VcsService Catalog

Discover every GitLab group a service account maintains and sync their projects into your Lunar catalog. Maps project topics to Lunar tags, filters by visibility, path and topic, and keeps the catalog current as projects are archived or deleted.

Add gitlab to your lunar-config.yml:
uses: github://earthly/lunar-lib/catalogers/gitlab@v1.0.5

What This Integration Syncs

This integration includes 1 cataloger that sync data from your systems.

Cataloger cron

groups

Discovers every top-level GitLab group the token's account is a maintainer of and catalogs each group's projects, including subgroups, as components.

  • Requires no list of groups: inviting the service account to a group onboards it
  • Or name the groups with include_groups, for a token that cannot list groups
  • Keeps groups and whole subgroup trees out with exclude_groups
  • Maps GitLab project topics to Lunar tags with a configurable prefix
  • Supports filtering by visibility (public, internal, private)
  • Supports include/exclude glob patterns on the project path
  • Supports topic allow/blocklists (allowed_topics / disallowed_topics)
  • Excludes archived projects by default; opt in to catalog them
Schedule: 0 3 * * *
service catalog auto-discovery gitlab group discovery subgroups
Book a demo

How Catalogers Fit into Lunar

Lunar catalogers sync component metadata into your Lunar catalog from external systems or source code. They can run on a schedule or be triggered by code changes to keep your service registry up-to-date.

By automatically discovering components from GitHub organizations, service registries, or by detecting technology usage in source code, catalogers ensure your guardrails apply to all relevant services without manual configuration.

Learn How Lunar Works →
1
⇄ Catalogers Sync Context This Integration
Sync component metadata from service catalogs, ownership systems, and external APIs
2
⚙ Guardrails Engine
Once cataloged, components are automatically analyzed by collectors and evaluated against your guardrails

Example Catalog Entry

This cataloger syncs component metadata into your Lunar catalog. Here's an example of a catalog entry it creates:

{ } catalog entry Catalog JSON
{
  "components": {
    "gitlab.com/acme/payments/payment-api": {
      "owner": "platform-team@acme.com",
      "tags": ["gl-backend", "gl-go", "gitlab-visibility-private"],
      "meta": {
        "description": "Payment processing API service",
        "visibility": "private",
        "archived": "false",
        "default_branch": "main",
        "project_id": "4218771",
        "group": "acme",
        "topics": "backend,go"
      }
    },
    "gitlab.com/globex/web/frontend-app": {
      "owner": "platform-team@globex.com",
      "tags": ["gl-react", "gitlab-visibility-internal"],
      "meta": {
        "description": "Customer-facing web application",
        "visibility": "internal",
        "archived": "false",
        "default_branch": "main",
        "project_id": "4218903",
        "group": "globex",
        "topics": "React"
      }
    }
  }
}

Configuration

Configure this cataloger in your lunar-config.yml.

Inputs

Input Required Default Description
gitlab_host Optional gitlab.com GitLab hostname to catalog from. Defaults to gitlab.com; set it to your instance hostname (e.g. gitlab.acme.com) for self-managed or Dedicated. A full URL such as "https://gitlab.acme.com" is also accepted — the scheme and any trailing path are stripped. Component IDs carry the host, so a project on a self-managed instance is keyed as gitlab.acme.com/<group>/<project>.
api_base_url Required — Full base URL of the GitLab REST API. Empty (the default) derives it from gitlab_host as https://<host>/api/v4. Set it only when your instance serves the API somewhere else.
include_groups Required — Comma-separated list of GitLab group paths to catalog, e.g. "acme,globex/platform". Only these are cataloged and the instance-wide group listing is skipped, so the token needs access only to them. Subgroups are always included. Empty (the default) discovers groups automatically.
exclude_groups Required — Comma-separated list of GitLab group paths to keep out, e.g. "acme/sandbox". Each excludes that group and everything beneath it, matched literally rather than as a glob. Works with or without include_groups.
include_public Optional true Include public projects
include_internal Optional true Include internal projects
include_private Optional true Include private projects
include_archived Optional false Include archived projects. Archived projects are excluded by default, so archiving a project removes its component on the next run. Set to "true" to keep cataloging them; they are then tagged archived and carry meta.archived = "true".
include_forks Optional true Include projects that are forks of another project. On by default. Set to "false" to catalog only projects that originate in the group, which is usually what you want when forks are working copies rather than services. A fork is tagged gitlab-fork and carries meta.fork plus meta.forked_from naming the project it came from.
include_personal_namespaces Optional false Also catalog projects in personal (user) namespaces that the token's account is a member of. Off by default, because group discovery cannot see a personal namespace at all — it is not a group — so these need a second pass over the account's own memberships. Projects found this way are tagged gitlab-personal-namespace. The sweep is deliberately scoped to memberships: an instance-admin token listing projects unscoped would return every project on the instance, including every user's scratch repositories.
include_projects Required — Comma-separated list of glob patterns for projects to include, matched against the project's full path within its host (e.g. "acme/payments/api-*"). Empty means include all. Because discovery is automatic, this and exclude_projects are how you narrow the catalog — a pattern like "acme/*" restricts it to one group.
exclude_projects Required — Comma-separated list of glob patterns for projects to exclude, matched against the project's full path within its host. Examples: "acme/sandbox/*,*/deprecated-*"
tag_prefix Optional gl- Prefix applied to each GitLab topic when it becomes a Lunar tag. Every topic on a project becomes one tag, lowercased and with runs of whitespace and parens collapsed to a dash before the prefix — the topic "Managed (Internal)" becomes the tag "gl-managed-internal". Lunar tags cannot contain whitespace or parens (both are delimiters in a policy's on: expression) and are matched case-sensitively, so that normalization always applies; the raw topics are kept in meta.topics. An empty string disables the prefix only, not the normalization.
allowed_topics Required — Comma-separated list of GitLab project topics used as an allowlist. When set, only projects carrying at least one of these topics are cataloged; empty (the default) disables the allowlist so every project passes. Both sides are normalized as described under tag_prefix before being compared, so "Infra Managed" and "infra-managed" are the same topic and either spelling works here. Examples: "lunar,catalog"
disallowed_topics Required — Comma-separated list of GitLab project topics used as a blocklist. A project carrying any of these topics is excluded, even when it also matches allowed_topics (block wins over allow). Normalized the same way as allowed_topics. Empty (the default) disables the blocklist. Examples: "archived,no-catalog"
default_owner Required — Default owner email for all components. Leave empty to not set owner.
domain_from_group_path Optional false Derive each component's domain from the GitLab group path that contains it, instead of giving every component the same domain. The project's namespace becomes a dotted domain — a project at acme/payments/payment-api lands in the domain "acme.payments" — so the catalog mirrors the group hierarchy without listing a single domain by hand. Every derived domain is registered under `.domains`, which the hub requires: a component pointing at an undeclared domain is dropped. Off by default; with it off, domains come only from default_domain.
default_domain Required — Domain for all components. Leave empty to not set a domain. The domain is stamped on every component's `.domain` field and registered under `.domains` so the catalog passes the hub's domain-reference validation. With domain_from_group_path enabled this becomes the root the derived hierarchy hangs under instead — acme/payments/payment-api with default_domain "eng" lands in "eng.acme.payments" — which is how you keep a GitLab estate under one top-level domain. A domain definition in lunar-config.yml still wins on merge.

Secrets

This cataloger requires the following secrets to be configured in Lunar:

Secret Description
GL_TOKEN GitLab access token with the `api` scope, belonging to the service account whose group memberships define what gets cataloged. Set it at cataloger scope: `lunar secret set GL_TOKEN --scope cataloger`.

Documentation

View on GitHub

GitLab Cataloger

Discovers the GitLab groups a service account maintains and catalogs their projects as Lunar components.

Overview

This cataloger syncs GitLab projects into the Lunar catalog without being told which groups to look in: it lists every top-level group the token's account is a maintainer of, then enumerates each group's projects including subgroups. Or set include_groups to name the groups instead, which skips that first step. It maps project topics to Lunar tags and supports filtering by visibility, project path, and topic. Onboarding a new group is an invite on the GitLab side, with no configuration change here — which is the point on an instance with many root-level groups. It works against gitlab.com as well as self-managed and Dedicated hosts via the gitlab_host input.

Synced Data

This cataloger writes to the following Catalog JSON paths:

Path Type Description
.components[*].owner string Default owner (if default_owner is configured)
.components[*].domain string Domain, from the group path with domain_from_group_path or from default_domain
.components[*].tags[] array Project topics, normalized and prefixed (e.g. gl-backend), plus gitlab-visibility-<visibility>, gitlab-archived on archived projects and gitlab-fork on forks
.components[*].meta.description string Project description
.components[*].meta.visibility string Project visibility (public, internal, private)
.components[*].meta.archived string Whether the project is archived ("true"/"false")
.components[*].meta.default_branch string Project default branch, omitted for a project with no commits
.components[*].meta.project_id string GitLab numeric project ID, stable across renames
.components[*].meta.group string Group the project was listed under
.components[*].meta.fork string Whether the project is a fork ("true"/"false")
.components[*].meta.namespace_kind string Namespace the project lives in (group or user)
.components[*].meta.forked_from string Path of the project this was forked from, absent when it is not a fork
.components[*].meta.topics string Raw GitLab topics, comma-separated, before tag normalization
.domains[*] object Registers every domain the run emits, so the catalog passes the hub's domain-reference validation

Component IDs are <host>/<project path>, using GitLab's canonical path_with_namespace — so a project in a subgroup is keyed as gitlab.com/acme/payments/payment-api, subgroups included. Taking the path from the API rather than from configuration is what keeps the casing canonical: Lunar's component identity is case-sensitive, so a hand-written group spelling that differs from GitLab's slug creates a second, dead identity for the same group.

Example Catalog JSON output
{
  "components": {
    "gitlab.com/acme/payments/payment-api": {
      "owner": "platform-team@acme.com",
      "tags": ["gl-backend", "gl-go", "gitlab-visibility-private"],
      "meta": {
        "description": "Payment processing API service",
        "visibility": "private",
        "archived": "false",
        "default_branch": "main",
        "project_id": "4218771",
        "group": "acme",
        "topics": "backend,go"
      }
    },
    "gitlab.com/globex/web/frontend-app": {
      "tags": ["gl-react", "gitlab-visibility-internal"],
      "meta": {
        "description": "Customer-facing web application",
        "visibility": "internal",
        "archived": "false",
        "default_branch": "main",
        "project_id": "4218903",
        "group": "globex",
        "topics": "React"
      }
    }
  }
}

Topics become tags, normalized

GitLab topics are free text — any ASCII bar linebreaks — unlike GitHub's, which are already slug-shaped. Passed through verbatim, three things break selection downstream:

  • A tag containing a space cannot be referenced from an on: expression at all. The expression is tokenized on whitespace, so on: "gl-infra-mgmt Managed" is a parse error — and it takes the whole expression with it, not just that one term.
  • Parens do the same thing: they are token delimiters in the expression grammar, so a topic like Managed (Internal) would yield a tag no on: can reference. Other punctuation (&, ,, .) lexes fine and is left alone.
  • Tag matching is case-sensitive, so the tag gl-Canonical is silently not matched by the natural on: [gl-canonical].

So topics are normalized before they become tags: lowercased, with runs of whitespace and parens collapsed to a single - and any leading or trailing - trimmed. Infra Managed becomes gl-infra-managed; Managed (Internal) becomes gl-managed-internal. The raw topics are preserved verbatim in meta.topics so nothing is lost. allowed_topics and disallowed_topics normalize both sides before comparing, so you can write either spelling in your config.

Forks and archived projects

Both states are filterable and both become tags, so they work as selectors in a policy's on: expression:

State Filter Tag
Archived include_archived (off by default) gitlab-archived
Fork include_forks (on by default) gitlab-fork
Personal namespace include_personal_namespaces (off by default) gitlab-personal-namespace

The tags are emitted on the projects that are in that state, not on both sides, because on: supports negation — on: "gitlab-fork" and on: "NOT gitlab-fork" both select, and a tag per project per state would double the tag count on every component for nothing. A fork also carries meta.forked_from naming the project it came from.

Fork detection costs no extra requests: GitLab returns forked_from_project in the group project listing, present only on actual forks, so there is no per-project lookup.

Personal namespaces need the extra pass because group discovery cannot see them — a personal namespace is not a group, so /groups/:id/projects never returns one no matter how privileged the token is. With include_personal_namespaces on, a second keyset sweep runs over /projects?membership=true and keeps only namespace.kind: "user"; the group projects in that listing are the ones the group sweep already has, so filtering on the namespace kind is also what stops the two passes duplicating each other.

That sweep is scoped to the account's memberships on purpose. An instance-admin token listing projects unscoped returns every project on the instance, every user's scratch repositories included — a blast radius nobody wants from a config flag. If the service account should see a particular personal namespace, add it to that project; blanket admin-wide discovery is out of scope here.

Project lifecycle

Each run reports the projects that exist now; the catalog is not additive. The Hub replaces this cataloger's previous contribution with the latest one and retires components that no longer appear, so the catalog tracks the estate rather than accumulating everything ever seen.

On GitLab In the catalog
Project created Becomes a component on the next run
Project deleted Its component is retired on the next run
Project archived Excluded by default, so its component is retired on the next run. With include_archived: "true" it stays, tagged gitlab-archived and with meta.archived: "true"
Project unarchived Returns on the next run
Project renamed or moved between groups Its path changes, so this is a retire plus a create: the old component is retired and a new one appears at the new path
Project scheduled for deletion Excluded, so its component is retired on the next run rather than lingering for the retention window
Group created, service account invited Discovered on the next run, with all its projects
Service account removed from a group The group's projects are retired on the next run

A GitLab delete is delayed, and that is why scheduled-for-deletion projects are excluded outright. DELETE on a project returns 202, renames it to <path>-deletion_scheduled-<id>, and keeps returning it from the project listing until the retention window expires — with archived still false, so the archived filter does not catch it. Cataloging those would keep a component alive under a mangled name for weeks after someone deleted the project, which is the opposite of what "deleted projects drop out on the next run" promises. marked_for_deletion_on comes back in the listing already, so the exclusion costs no extra requests.

A rename does not carry component history across, because Lunar's component identity is the project path. meta.project_id carries GitLab's numeric project ID, which is stable across renames, so the two components can be correlated after the fact.

Because retirement follows from absence, a run that fails partway must not report a partial set. The cataloger exits non-zero on an API error rather than writing what it managed to read — a failed run leaves the previous catalog in place, which is better than mass-retiring components because of one bad response. The same reasoning applies per group: a group that fails to enumerate aborts the run rather than silently dropping its projects.

Default branch

meta.default_branch is informational. The cataloger deliberately does not set the Catalog JSON branch field: a cataloger-declared branch is taken verbatim, so a branch read on a nightly schedule would go stale between runs, and a component pinned to a branch that no longer exists collects nothing. Leaving it unset lets the Hub resolve each component's default branch live.

Catalogers

This plugin provides the following catalogers:

Cataloger Description
groups Discovers every top-level group the token's account maintains and catalogs their projects, including subgroups

Hook Type

Hook Schedule Description
cron 0 3 * * * Runs daily at 3am UTC

Installation

Add to your lunar-config.yml:

catalogers:
  - uses: github://earthly/lunar-lib/catalogers/gitlab@v1.0.0

Then set the token at cataloger scope — the default scope is collector, and a secret in the wrong scope is invisible to this plugin:

lunar secret set GL_TOKEN --scope cataloger

That is the whole configuration for the common case. There is no list of groups to maintain: the token's own group memberships are the scope, so you bring a group into the catalog by inviting the service account to it.

Self-managed and Dedicated

Point gitlab_host at your instance:

catalogers:
  - uses: github://earthly/lunar-lib/catalogers/gitlab@v1.0.0
    with:
      gitlab_host: "gitlab.acme.com"

This matches the instance service account model, where one account is a maintainer of every top-level group Lunar serves. On gitlab.com, where group service accounts are per-group, a token still discovers exactly the groups its account belongs to — several tokens means several instances of this cataloger, distinguished with name: on the uses: line.

Narrowing the catalog

Discovery is automatic, so scoping is done with path globs rather than a group list. Both match the project's full path within the host, so the leading segment is the group:

catalogers:
  - uses: github://earthly/lunar-lib/catalogers/gitlab@v1.0.0
    with:
      include_projects: "acme/*,globex/platform/*"   # only these
      exclude_projects: "*/sandbox/*,*/deprecated-*" # never these

Naming the groups instead of discovering them

include_groups catalogs only the groups you list and skips the GET /groups call, which is what a token that cannot list groups instance-wide needs. exclude_groups drops a group and everything beneath it, with or without include_groups.

catalogers:
  - uses: github://earthly/lunar-lib/catalogers/gitlab@v1.0.0
    with:
      include_groups: "acme,globex/platform"
      exclude_groups: "acme/sandbox"

Entries are group paths and may be subgroups; subgroups are always included, so naming a parent covers its subtree. Both match literally, not as globs, so acme/sandbox leaves acme/sandbox-tools alone. A path GitLab does not return aborts the run, like any other failed group — skipping it would retire every component under it.

Advanced configuration

catalogers:
  - uses: github://earthly/lunar-lib/catalogers/gitlab@v1.0.0
    with:
      gitlab_host: "gitlab.acme.com"
      include_public: "true"
      include_internal: "true"
      include_private: "true"
      include_archived: "false"
      exclude_projects: "*/sandbox/*"
      tag_prefix: "gl-"
      default_owner: "platform-team@acme.com"
      default_domain: "platform"

When default_domain is set, every discovered component gets that domain on its .domain field, and the domain is registered under .domains so the catalog passes the Hub's domain-reference validation. A domain definition in lunar-config.yml (or a later cataloger) takes precedence on merge, so you can set a richer description and owner there without this cataloger clobbering it.

Domains from the group hierarchy

GitLab groups already encode a hierarchy, and Lunar domains are dotted paths, so the two map onto each other directly. Set domain_from_group_path: "true" and each component lands in a domain named after the group path that contains it:

acme/payments/payment-api   ->  domain  acme.payments
acme/web/frontend-app       ->  domain  acme.web
globex/checkout             ->  domain  globex

default_domain then becomes the root the hierarchy hangs under rather than a flat value — with default_domain: "eng" those become eng.acme.payments, eng.acme.web, eng.globex. That is how you keep a whole GitLab estate beneath one top-level domain.

Two details worth knowing. Every domain the run derives is registered under .domains in the same run, before the components that reference it — the Hub drops a component whose domain it cannot resolve, so for derived domains this is load-bearing rather than a formality. And a . inside a group path segment is folded to - (acme/v1.2/svc → acme.v1-2), because the dot is the domain separator and would otherwise invent a hierarchy level that does not exist in GitLab.

Leave it off (the default) and nothing changes: domains come only from default_domain, and with that empty the cataloger writes no domain at all, so components fall into the Hub's reserved other.

Filter by topic (allowlist / blocklist)

You can opt projects into the catalog by GitLab topic. Tag the projects you want cataloged and set allowed_topics:

catalogers:
  - uses: github://earthly/lunar-lib/catalogers/gitlab@v1.0.0
    with:
      allowed_topics: "lunar"          # only projects carrying the `lunar` topic
      disallowed_topics: "no-catalog"  # …but never projects carrying `no-catalog`
  • allowed_topics — when set, a project is cataloged only if it carries at least one of the listed topics. Empty (default) means no allowlist: every project passes.
  • disallowed_topics — a project carrying any of the listed topics is excluded. Block wins over allow.

Both lists compose with the visibility and path-pattern filters — a project must pass all of them.

Source System

This cataloger reads the GitLab REST API (/api/v4) with curl, authenticating with the GL_TOKEN secret sent as a PRIVATE-TOKEN header. The token needs the api scope and belongs to a service account that is a maintainer of each top-level group Lunar should serve — the same account and token the Hub itself uses for GitLab. Its group memberships are the cataloger's scope, so no group list is configured anywhere. The same token works for gitlab.com, self-managed and Dedicated instances; only gitlab_host changes.

Discovery and scale

Two steps per run. First, /groups?top_level_only=true&min_access_level=40 lists the top-level groups where the account holds at least the maintainer role — only groups it is a member of, not every group on the instance. That level is fixed rather than configurable on purpose: maintainer is what the Hub itself requires of the GitLab service account, and group membership at that level is how the Hub decides what to serve, so the cataloger discovers exactly the groups the Hub can work with. Cataloging a group below it would create components whose webhooks can never be registered, which show up and then sit at zero checks forever. Then each group's projects are listed from /groups/:id/projects?include_subgroups=true, which covers the whole subtree in one pass, so subgroups are never enumerated separately. include_groups skips straight to that second step — GitLab takes a URL-encoded path wherever it takes a group ID.

Project listing uses keyset pagination (order_by=id&sort=desc plus id_before), walking newest-first until a page comes back empty. Descending is the endpoint's default order; ascending has timed out server-side (HTTP 500) even on small groups. There is no configured ceiling on the number of projects or groups: a cap that silences itself is worse than a long run, so the cataloger pages until GitLab says there are no more.

A listing page that fails with HTTP 500 is retried with simple=true, GitLab's lighter project entity. Before GitLab 18.2 that entity has no visibility, so the cataloger keeps retrying with full details instead. Projects from a simple page are cataloged as non-forks, and as unarchived when include_archived is on.

Two pagination details are worth knowing, because both look like they work and don't:

  • The pagination=keyset query parameter is not honoured on /groups/:id/projects. GitLab accepts it and still returns offset-paginated Link headers, including a rel="last". Passing id_before explicitly is what makes the paging genuinely keyset, and is why the cataloger does not follow Link: rel="next".
  • On /groups, id_after is ignored entirely — that endpoint is offset-only. Group discovery therefore pages by page=N to the last page. It is bounded by the number of groups rather than projects, so offset paging is cheap there.

Projects shared into a group but owned elsewhere are excluded (with_shared=false). GitLab includes them by default, which would otherwise catalog projects outside the account's groups and duplicate any project shared into more than one of them.

Rate limits

GitLab returns RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset on every API response. The cataloger reads them to pace itself as the remaining budget runs low, and retries 429 and 5xx responses with exponential backoff, honouring Retry-After when GitLab sends it. Requests that fail for a non-transient reason (401, 403, 404) abort the run rather than shrinking the reported project set. Every failed attempt logs the response headers and body, including the X-Request-Id GitLab support asks for.

Open Source

This cataloger is open source and available on GitHub. Contribute improvements, report issues, or fork it for your own use.

View Repository

Ready to Automate Your Standards?

See how Lunar can turn your AGENTS.md, engineering wiki, compliance docs, or postmortem action items into automated guardrails with our 200+ built-in guardrails.

Works with any process
check AI agent rules & prompt files
check Post-mortem action items
check Security & compliance policies
check Testing & quality requirements
Auto-ID My Guardrails
Paste your AGENTS.md or manual process doc and get guardrails in minutes
Book a Demo