Backstage catalog-info.yaml Monorepo Cataloger
Scans each configured repository for every `catalog-info.yaml` — not just the root — and creates one Lunar component per file, keyed to the file's directory so a monorepo becomes one component per service. Each gets the owner, domain, and tags from its Backstage entity.
backstage-catalog-info-monorepo to your lunar-config.yml:uses: github://earthly/lunar-lib/catalogers/backstage-catalog-info-monorepo@v1.0.5
What This Integration Syncs
This integration includes 1 cataloger that sync data from your systems.
discover
Walks each configured repository's file tree via the GitHub
Git Trees API (one recursive call per repo), finds every
catalog-info.yaml / catalog-info.yml, fetches each via the
Contents API, parses it (multi-document supported), and CREATES
one component per discovered file in the Catalog JSON. Each
component is keyed to the file's location: a root file maps to
<component_id_prefix><owner>/<repo>, and a file at
<dir>/catalog-info.yaml maps to the monorepo subcomponent
<component_id_prefix><owner>/<repo>/<dir>. The matching
Component entity's owner, domain, and tags are written onto
the created component (identical shaping to the
backstage-catalog-info cataloger), plus a .domains stub for
every referenced domain. Runs on a fixed cadence.
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": {
"github.com/acme/monorepo/services/payments": {
"owner": "group:default/team-payments",
"domain": "platform.payments",
"tags": ["bs-payments", "bs-type-service", "bs-lifecycle-production"]
},
"github.com/acme/monorepo/services/web": {
"owner": "group:default/team-web",
"domain": "platform.frontend",
"tags": ["bs-frontend", "bs-type-website", "bs-lifecycle-production"]
}
},
"domains": {
"platform.payments": {},
"platform.frontend": {}
}
}
Configuration
Configure this cataloger in your lunar-config.yml.
Inputs
| Input | Required | Default | Description |
|---|---|---|---|
repos
|
Required | — | Comma-separated list of `<owner>/<repo>` repositories to scan (e.g. `acme/monorepo,acme/platform`). Each is walked in full for `catalog-info.yaml` files. Required — leave empty and the cataloger has nothing to scan. Org-wide auto-discovery (scan every repo in an org, or every repo already cataloged by `github-org`) is a planned follow-up; see the README. |
filenames
|
Optional |
catalog-info.yaml,catalog-info.yml
|
Comma-separated list of file basenames that count as a Backstage catalog descriptor. A file anywhere in the repo tree whose basename matches is treated as a catalog-info file. Defaults match the conventional Backstage names. |
branch
|
Required | — | Git ref to read the tree and files from. Empty means each repo's default branch. |
exclude_paths
|
Optional |
catalog-info.yaml,catalog-info.yml
|
Comma-separated list of file paths (relative to the repo root) to skip during discovery. An entry matches a discovered file by exact path or as a glob (e.g. `legacy/*/catalog-info.yaml`). Excluded files never become components. The default excludes a root `catalog-info.yaml` / `.yml`, because this cataloger is designed to run alongside the `backstage-catalog-info` cataloger, which already reads the root file onto the repo-level component — so out of the box this one stays out of the root and just adds the per-subdirectory subcomponents, no double-writes. Set it to an empty string to also process the root file (mapping it to the repo-level component id) when you run this cataloger on its own, or extend it to fence off additional paths. This is the platform-controlled exclusion: it lives in `lunar-config.yml`, so dev teams can't override it from their repos. |
component_id_prefix
|
Optional |
github.com/
|
String prepended to `<owner>/<repo>[/<dir>]` when constructing the created component's id. `github.com/` yields ids like `github.com/acme/monorepo/services/payments`. |
domain_annotation
|
Required | — | Annotation key used to source the component's domain when `spec.domain` is absent. When set and the entity carries this annotation, its value wins over `spec.domain` / `spec.system`. Leave empty to use the canonical Backstage fields only. Same semantics as the `backstage-catalog-info` cataloger. |
tag_prefix
|
Optional |
bs-
|
Prefix added to Backstage `metadata.tags` (and to derived `type-*` / `lifecycle-*` tags) when mapped to Lunar tags. Empty string disables the prefix. Same semantics as the `backstage-catalog-info` cataloger. |
include_derived_tags
|
Optional |
true
|
When `true`, emits derived tags from `spec.type` (e.g. `bs-type-service`) and `spec.lifecycle` (e.g. `bs-lifecycle-production`) in addition to `metadata.tags`. |
owner_format
|
Optional |
as-is
|
How to write `spec.owner` into the Lunar `owner` field. - `as-is` — pass the Backstage entity ref through verbatim (e.g. `group:default/team-payments`). - `bare-name` — strip the `<kind>:<namespace>/` prefix and write only the trailing name. Same semantics as the `backstage-catalog-info` cataloger. |
default_owner
|
Required | — | Fallback owner applied (verbatim) to a created component whose matched entity has no `spec.owner`. Leave empty to skip. |
default_domain
|
Required | — | Fallback domain applied (verbatim) to a created component that resolves to no domain. A stub `.domains["<default_domain>"]` entry is written alongside so hub domain validation accepts the reference. Leave empty to leave such components without a domain. |
allow_ignore_annotation
|
Optional |
false
|
When `true`, a discovered `Component` whose `catalog-info.yaml` carries the ignore annotation (see `ignore_annotation`) set to a truthy value (`true` / `yes` / `1`) is skipped — no component is created for it. This delegates opt-out to the dev teams that own the `catalog-info.yaml` files. Leave `false` (the default) to ignore the annotation entirely, keeping exclusion under platform control via `exclude_paths`. |
ignore_annotation
|
Optional |
lunar.io/ignore
|
Annotation key checked when `allow_ignore_annotation` is `true`. A `Component` with this annotation set to `true` / `yes` / `1` is skipped. Ignored unless `allow_ignore_annotation` is enabled. |
Secrets
This cataloger requires the following secrets to be configured in Lunar:
| Secret | Description |
|---|---|
GH_TOKEN
|
GitHub token used to walk each repo's tree (Git Trees API) and fetch each `catalog-info.yaml` (Contents API). Needs `Contents: Read` on the target repos (`repo` scope on a classic PAT, or `contents: read` on a fine-grained PAT / GitHub App installation token). Reused across most lunar-lib GitHub plugins, so if `GH_TOKEN` is already set for `github-org` this cataloger picks it up automatically. |
Documentation
View on GitHubBackstage catalog-info.yaml Monorepo Cataloger
Discovers every catalog-info.yaml in a repository — including files in subdirectories — and creates one Lunar component per discovered file.
Overview
Where the backstage-catalog-info cataloger augments components that already exist (reading one catalog-info.yaml per repo), this cataloger creates components by discovering all the catalog-info.yaml files a repository contains and mapping each to its own Lunar component. A monorepo with services/payments/catalog-info.yaml and services/web/catalog-info.yaml becomes two components, each keyed to the file's directory and populated with the owner, domain, and tags from its Backstage Component entity.
It runs on a schedule, walking each configured repository's file tree via the GitHub API. Use it when a single repo holds several Backstage entities in different subdirectories and each should be its own component.
Synced Data
This cataloger writes to the following Catalog JSON paths, once per discovered catalog-info.yaml:
| Path | Type | Description |
|---|---|---|
.components["<id>"] |
object | Created for each discovered file. <id> is <component_id_prefix><owner>/<repo> for a root file, or <component_id_prefix><owner>/<repo>/<dir> for a file at <dir>/catalog-info.yaml |
.components["<id>"].owner |
string | spec.owner of the matched Backstage Component (or default_owner fallback) |
.components["<id>"].domain |
string | metadata.annotations[<domain_annotation>] when configured and present; otherwise spec.domain, falling back to spec.system, then to default_domain |
.components["<id>"].tags[] |
array | metadata.tags plus derived type-* / lifecycle-* tags, all with tag_prefix |
.domains["<domain>"] |
object | Stub entry ({}) for each domain a created component references, so hub catalog validation accepts the reference. When the same catalog-info.yaml declares a matching kind: Domain / kind: System entity, its description and owner are propagated |
The owner / domain / tag shaping is identical to the backstage-catalog-info cataloger — the same inputs (domain_annotation, tag_prefix, include_derived_tags, owner_format, default_owner, default_domain) apply. The difference is that this cataloger creates the component keyed to the discovered file's path, rather than augmenting an existing one.
Example Catalog JSON output (a monorepo with two services)
{
"components": {
"github.com/acme/monorepo/services/payments": {
"owner": "group:default/team-payments",
"domain": "platform.payments",
"tags": ["bs-payments", "bs-type-service", "bs-lifecycle-production"]
},
"github.com/acme/monorepo/services/web": {
"owner": "group:default/team-web",
"domain": "platform.frontend",
"tags": ["bs-frontend", "bs-type-website", "bs-lifecycle-production"]
}
},
"domains": {
"platform.payments": {
"description": "Payments platform — billing, ledger, settlement",
"owner": "group:default/team-payments"
},
"platform.frontend": {}
}
}
How Files Are Discovered and Keyed
-
Tree walk. For each repository in
repos, the cataloger makes one recursive Git Trees API call (GET /repos/<owner>/<repo>/git/trees/<branch>?recursive=1) and filters the tree to blobs whose basename is infilenames(defaultcatalog-info.yaml,catalog-info.yml). One API call finds every descriptor in the repo, regardless of nesting depth. -
Fetch + parse. Each matched file is fetched via the Contents API (raw) and parsed (multi-document files supported).
-
Component identity. The created component's id is derived from the file's location:
File location Component id (with default prefix) catalog-info.yaml(repo root)github.com/<owner>/<repo>services/payments/catalog-info.yamlgithub.com/<owner>/<repo>/services/paymentsThe subdirectory form matches Lunar's convention for monorepo subcomponents (
github.com/acme/monorepo/service-a), so the component's path is the discovered file's directory — exactly the shape a monorepo needs. -
Entity selection. One component is created per discovered file. If a file declares exactly one
kind: Component, that entity is used.
Restricting Synced Kinds
This cataloger only creates components from kind: Component entities. Domain, System, API, Resource, User, Group, Location, etc. are ignored for component creation — a Domain / System matching a created component's domain is still read to enrich the .domains stub (description + owner).
Files With Multiple Components
The common monorepo layout is one catalog-info.yaml per service directory, each declaring a single Component — one file, one component. A file that declares zero Component entities (e.g. only a System or Domain) or more than one Component is skipped, with a log line. This cataloger creates exactly one component per file and keys it to the file's directory, so it won't guess which of several Components a multi-Component file maps to — give each Component its own directory's catalog-info.yaml to have it discovered. (A Domain / System alongside the single Component is still read to enrich the .domains stub.)
Catalogers
| Cataloger | Description |
|---|---|
discover |
Scheduled. Walks each configured repo's tree via the Git Trees API, finds every catalog-info.yaml, fetches and parses each, and creates one component per file keyed to the file's directory. Writes owner / domain / tags plus .domains stubs. Runs on a cron schedule. Requires GH_TOKEN. |
Hook Type
| Cataloger | Hook | Schedule / Trigger | Description |
|---|---|---|---|
discover |
cron |
0 3 * * * |
Runs daily at 03:00 UTC, once per run (global), scanning every repo in repos |
The cron hook is global (once per run, no repo checkout), which is what lets this cataloger create components — per-component hooks (component-cron, component-repo) can only augment components that already exist. Daily at 03:00 is offset from the standard 0 2 * * * so it lands after component-defining catalogers run. Tighten the cadence by overriding hook.schedule in a fork.
Installation
Add to your lunar-config.yml and list the repositories to scan:
catalogers:
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info-monorepo@v1.0.0
with:
repos: "acme/monorepo,acme/platform"
Set the GitHub token used to walk each repo's tree and fetch each catalog-info.yaml:
lunar secret set GH_TOKEN <your-github-token>
The token needs Contents: Read on every repo in repos (repo scope on a classic PAT; contents: read on a fine-grained PAT or GitHub App installation token). Many lunar-lib plugins reuse the same GH_TOKEN, so if you've already set it for github-org or the GitHub-API collectors, this cataloger picks it up automatically. All other inputs (filenames, branch, exclude_paths, component_id_prefix, domain_annotation, tag_prefix, include_derived_tags, owner_format, default_owner, default_domain, allow_ignore_annotation, ignore_annotation) are documented in lunar-cataloger.yml.
Layering With Component-Defining Catalogers
This cataloger and github-org both create components, at different granularities:
github-orgcreates one repo-level component per repository (github.com/acme/monorepo).backstage-catalog-info-monorepocreates one component percatalog-info.yaml— for a monorepo, that's a subcomponent per service directory (github.com/acme/monorepo/services/payments).
Running both is the intended monorepo setup: github-org (and/or the augment backstage-catalog-info) owns the repo-level component, and this cataloger adds one subcomponent per catalog-info file. By default exclude_paths (catalog-info.yaml,catalog-info.yml) excludes a root catalog-info.yaml, so this cataloger creates subcomponents only and never competes for the repo-level id. Clear exclude_paths to also map a root file to github.com/<owner>/<repo> when you run this cataloger on its own.
Monorepo vs the augment cataloger
backstage-catalog-info-monorepo and backstage-catalog-info are complementary, not alternatives:
backstage-catalog-info-monorepo (this) |
backstage-catalog-info |
|
|---|---|---|
| Action | Creates components | Augments existing components |
| Files per repo | Discovers all catalog-info.yaml files |
Reads one (first configured path) |
| Granularity | One component per file (monorepo subcomponents) | One existing component per repo |
| Hook | cron (global — can create) |
component-cron / component-repo (per-component — augment only) |
If you only need to enrich repo-level components that another cataloger already created, use backstage-catalog-info. If a repo holds several catalog-info files that should each become their own component, use this cataloger.
Running both on the same repo
Enabling both together is the expected setup, and by default they divide cleanly with no shared writes:
- Subdirectory files (
services/*/catalog-info.yaml, …) are exclusive to this cataloger. The augment cataloger only reads the first configured path (root-relative) and never descends, so it never sees them. - A root
catalog-info.yamlis left to the augment cataloger. Becauseexclude_pathsdefaults tocatalog-info.yaml,catalog-info.yml, this cataloger skips the root file and only creates subcomponents — so the two never write the same component id.
If you instead run this cataloger on its own and want the root file to produce the repo-level component too, clear exclude_paths. A root file then maps to github.com/<owner>/<repo> — the same id the augment cataloger and github-org use. That's still an idempotent re-assert when they share the same transform inputs, but keeping the default exclude_paths alongside backstage-catalog-info avoids any chance of the two writing divergent values (e.g. different tag_prefix) to the repo-level component.
Excluding files and components
Two mechanisms decide what does not become a component, at different levels of control:
exclude_paths(platform-controlled) — a list of repo-relative paths/globs skipped before a component is ever created. It lives inlunar-config.yml, so dev teams can't override it. The default excludes the rootcatalog-info.yaml; add globs likelegacy/*/catalog-info.yamlto fence off more.lunar.io/ignoreannotation (dev-delegated, gated) — setallow_ignore_annotation: trueand anyComponentwhosecatalog-info.yamlcarrieslunar.io/ignore: "true"(key configurable viaignore_annotation) opts itself out. Left off by default, so opt-out stays platform-controlled unless you choose to delegate it.
Targeting monorepos in a mixed fleet (monorepo + polyrepo)
An org often has both polyrepos (one service per repo, root catalog-info.yaml → the repo-level component) and monorepos (many services in subdirectories, and no root component wanted). The two strategies coexist without conflict: target the monorepos explicitly and let the normal repo-level flow handle everything else.
catalogers:
# Repo-level components for the whole org, EXCEPT the monorepos.
- uses: github.com/earthly/lunar-lib/catalogers/github-org@v1.0.0
with:
org_name: "acme"
exclude_repos: "big-monorepo" # no repo-level component for the monorepo
# Polyrepos: augment each root catalog-info onto its repo-level component.
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info@v1.0.0
# Monorepos: one component per subdirectory catalog-info, root excluded by default.
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info-monorepo@v1.0.0
with:
repos: "acme/big-monorepo"
Each polyrepo gets a repo-level component from its root catalog-info.yaml; the monorepo gets one component per subdirectory and no root component. The flows don't overlap: github-org excludes the monorepo, and this cataloger is scoped to it by an explicit repos list rather than running across every repo. (The augment cataloger harmlessly no-ops on the monorepo's subcomponents — it looks for a root file at a path that isn't a real repo and skips on the 404.) This is the "target the monorepo directly" posture; because targeting is just the repos list plus github-org's exclude_repos, a fleet can mix both strategies without a one-size-fits-all cataloger.
Source System
GitHub — the cataloger calls the Git Trees API once per configured repo to enumerate files, then the Contents API once per discovered catalog-info.yaml. Requirements:
GH_TOKENsecret with read access to every repo inrepos.- GitHub-hosted repos. Component ids are constructed as
<component_id_prefix><owner>/<repo>[/<dir>]; the default prefix isgithub.com/.
Scope and Roadmap
v1 targets the single-repo / explicit-repo-list case that motivated it (a monorepo whose services each ship a catalog-info.yaml). Two extensions are noted as follow-ups rather than built here:
- Org-wide discovery — scan every repo in an org (or every repo already cataloged by
github-org) instead of an explicitreposlist. - Commit-triggered variant — a checkout-based companion (mirroring
backstage-catalog-info'saugment-on-commit) for near-real-time updates without the daily cron.
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.