GitLab Cataloger
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.
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.
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
0 3 * * *
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 →Example Catalog Entry
This cataloger syncs component metadata into your Lunar catalog. Here's an example of a catalog entry it creates:
{
"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 GitHubGitLab 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, soon: "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 noon:can reference. Other punctuation (&,,,.) lexes fine and is left alone. - Tag matching is case-sensitive, so the tag
gl-Canonicalis silently not matched by the naturalon: [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=keysetquery parameter is not honoured on/groups/:id/projects. GitLab accepts it and still returns offset-paginatedLinkheaders, including arel="last". Passingid_beforeexplicitly is what makes the paging genuinely keyset, and is why the cataloger does not followLink: rel="next". - On
/groups,id_afteris ignored entirely — that endpoint is offset-only. Group discovery therefore pages bypage=Nto 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.
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.