Backstage catalog-info.yaml Cataloger
Augments existing Lunar components with owner, domain, tag, and annotation-sourced meta read from each repo's `catalog-info.yaml` — on a schedule via the GitHub API, or the moment a repo is committed to. Runs per component. Use when Backstage data lives in repo files, not a Backstage server.
backstage-catalog-info to your lunar-config.yml:uses: github://earthly/lunar-lib/catalogers/backstage-catalog-info@v1.0.5
What This Integration Syncs
This integration includes 2 catalogers that sync data from your systems.
augment
from the component's GitHub repo via the Contents API, parses it
(supports multi-document YAML), picks the matching Component
entity, and writes its owner, domain, tags, and selected
annotation-sourced meta to .components["$LUNAR_COMPONENT_ID"]
in the Catalog JSON. Repos with no catalog-info.yaml, an
unparseable file, or no matching Component are skipped
silently. Runs on a fixed cadence regardless of repo activity —
pair with augment-on-commit (below) for updates the moment a
repo's catalog-info.yaml changes instead of at the next cron.
0 3 * * *
augment-on-commit
Commit-triggered companion to augment: identical owner /
domain / tag / meta augmentation, but fired the moment a
component's repo receives a commit instead of waiting for the
daily cron.
Runs via the component-repo hook, which checks out the
component's repo, so it reads catalog-info.yaml straight from
the working tree — no GitHub Contents API call and no GH_TOKEN
required. Same matching rules, same inputs (except branch,
which only applies to the API-fetch augment variant), and the
same silent-skip behavior. Like augment, it augments existing
components only — a component-defining cataloger such as
github-org must run first — and cannot create new ones.
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/payment-api": {
"owner": "group:default/team-payments",
"domain": "platform.payments",
"tags": ["bs-payments", "bs-tier1", "bs-type-service", "bs-lifecycle-production"],
"meta": {"pagerduty/service-id": "PABC123"}
},
"github.com/acme/web-app": {
"owner": "group:default/team-web",
"domain": "platform.frontend",
"tags": ["bs-frontend", "bs-type-website", "bs-lifecycle-production"]
}
}
}
Configuration
Configure this cataloger in your lunar-config.yml.
Inputs
| Input | Required | Default | Description |
|---|---|---|---|
paths
|
Optional |
catalog-info.yaml,catalog-info.yml
|
Comma-separated list of file paths to try in the component's repo. First match wins. Defaults match the conventional Backstage locations. |
branch
|
Required | — | Git ref to read `catalog-info.yaml` from. Empty means the repo's default branch. Applies to the `augment` (Contents API) variant only — `augment-on-commit` reads whatever ref the `component-repo` hook checked out, so this input is ignored there. |
component_id_annotation
|
Optional |
github.com/project-slug
|
Annotation key on a Backstage Component used to match it against the current Lunar component. The cataloger picks the entity whose `metadata.annotations[<key>]` value, prefixed with `component_id_prefix`, equals `$LUNAR_COMPONENT_ID`. If the file has exactly one Component entity and no annotation, that single entity is used (the single-Component-per-repo case). |
component_id_prefix
|
Optional |
github.com/
|
String prepended to the annotation value when matching against `$LUNAR_COMPONENT_ID`. |
domain_annotation
|
Required | — | Annotation key on a Backstage Component used to source the domain when `spec.domain` is absent. Useful for orgs that model domains via a custom annotation rather than the canonical Backstage `spec.domain` field. When set and the matched Component carries this annotation, its value wins over `spec.domain` / `spec.system`. Leave empty to use the canonical Backstage fields only. |
tag_prefix
|
Optional |
bs-
|
Prefix added to Backstage `metadata.tags` when mapped to Lunar tags. Also applied to derived tags like `type-<spec.type>` and `lifecycle-<spec.lifecycle>`. Empty string disables the prefix. |
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` from the catalog-info file into the Lunar `owner` field. Backstage entity refs typically look like `group:default/team-payments` or `user:default/jane`. - `as-is` — pass the value through verbatim. Matches what the existing `policies/backstage/*` checks accept (`team-payments`, `group:infra`, `user:alice` are all valid). - `bare-name` — strip the `<kind>:<namespace>/` prefix and write only the trailing name (e.g. `team-payments`). |
default_owner
|
Required | — | Fallback owner applied (verbatim) to components whose matched Backstage entity has no `spec.owner`. Leave empty to skip. |
default_domain
|
Required | — | Fallback domain applied (verbatim) to components whose matched Backstage entity resolves to no domain — i.e. none of `domain_annotation`, `spec.domain`, or `spec.system` is set. A stub `.domains["<default_domain>"]` entry is written alongside so the hub's domain validation accepts the reference. Leave empty to leave such components without a domain. |
meta_annotations
|
Optional |
pagerduty.com/service-id=pagerduty/service-id
|
Comma-separated `<annotation-key>=<meta-key>` pairs. For each pair, if the matched Backstage Component carries the annotation `metadata.annotations[<annotation-key>]`, its value is written to the Lunar component's `meta[<meta-key>]`. This is how tool collectors discover their per-component IDs from catalog-info: the `pagerduty` collector reads `pagerduty/service-id` from the component meta, and the default here sources it from the `pagerduty.com/service-id` annotation recommended by PagerDuty's Backstage integration guide. Add pairs to feed other collectors, e.g. `pagerduty.com/service-id=pagerduty/service-id,sonarqube.io/project-key=sonarqube/project-key`. Whitespace around pairs and the `=` is trimmed. Set to empty to write no meta. |
ignore_components
|
Required | — | Comma-separated list of component ids to skip — no augmentation, no write. An entry matches `$LUNAR_COMPONENT_ID` by exact id or as a glob (e.g. `github.com/acme/legacy-*`). This is the platform-controlled exclusion: it lives in `lunar-config.yml`, so dev teams can't override it from their repos. Use it to keep specific components out of the catalog regardless of what their `catalog-info.yaml` says. |
allow_ignore_annotation
|
Optional |
false
|
When `true`, a matched `Component` whose `catalog-info.yaml` carries the ignore annotation (see `ignore_annotation`) set to a truthy value (`true` / `yes` / `1`) is skipped — that component is not augmented. 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 `ignore_components`. |
ignore_annotation
|
Optional |
lunar.io/ignore
|
Annotation key checked when `allow_ignore_annotation` is `true`. A matched `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 by the `augment` variant to fetch `catalog-info.yaml` from component repos via the 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). Not required by `augment-on-commit`, which reads the file from the `component-repo` checkout; if you run only that variant you can leave `GH_TOKEN` unset. |
Documentation
View on GitHubBackstage catalog-info.yaml Cataloger
Augments existing Lunar components with metadata read from each repo's catalog-info.yaml, fetched directly via the GitHub Contents API.
Overview
Augments existing Lunar components with owner, domain, tag, and meta metadata read from each repo's catalog-info.yaml — it picks the matching Component entity and writes owner / domain / tags / meta to that component's catalog entry. The same augmentation runs in two trigger variants: augment refreshes the catalog on a daily schedule (fetching the file via the GitHub Contents API), while augment-on-commit updates a component the moment its repo is committed to (reading the file from the checkout, no token). Both only enrich components that already exist, so pair this with a component-defining cataloger such as github-org.
Synced Data
This cataloger writes to the following Catalog JSON paths on each run:
| Path | Type | Description |
|---|---|---|
.components["$LUNAR_COMPONENT_ID"].owner |
string | spec.owner of the matched Backstage Component (or default_owner fallback) |
.components["$LUNAR_COMPONENT_ID"].domain |
string | metadata.annotations[<domain_annotation>] of the matched Component when domain_annotation is configured and the annotation is present; otherwise spec.domain, falling back to spec.system, then to the configured default_domain when none of those is set |
.components["$LUNAR_COMPONENT_ID"].tags[] |
array | metadata.tags plus derived type-* / lifecycle-* tags, all with tag_prefix |
.components["$LUNAR_COMPONENT_ID"].meta |
object | Key/value meta sourced from annotations per the meta_annotations mapping. By default maps the pagerduty.com/service-id annotation onto pagerduty/service-id, which the pagerduty collector reads. Omitted entirely when no mapped annotation is present. |
.domains["<domain>"] |
object | Stub entry ({}) for each domain a component references. Hub catalog validation rejects components that reference unknown domains, so the cataloger writes the stub before the component entry. When the same catalog-info.yaml declares a matching kind: Domain or kind: System entity, its metadata.description and spec.owner are propagated into the stub. |
This cataloger does not define new components — both the component-cron and component-repo hooks augment existing components only. Pair with a component-defining cataloger (see Layering). Domain entries are written as stubs only; for a richer global domain catalog, layer with the backstage cataloger.
Example Catalog JSON output (across multiple component runs)
{
"components": {
"github.com/acme/payment-api": {
"owner": "group:default/team-payments",
"domain": "platform.payments",
"tags": ["bs-payments", "bs-tier1", "bs-type-service", "bs-lifecycle-production"],
"meta": {"pagerduty/service-id": "PABC123"}
},
"github.com/acme/web-app": {
"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": {}
}
}
Catalogers
| Cataloger | Description |
|---|---|
augment |
Scheduled. Fetches catalog-info.yaml from the current component's GitHub repo via the Contents API, parses the YAML (multi-document files supported), picks the matching Component entity, and writes its owner / domain / tags / meta to .components["$LUNAR_COMPONENT_ID"] in the Catalog JSON. Runs on a component-cron schedule. Requires GH_TOKEN. |
augment-on-commit |
Commit-triggered. Same parsing, matching, and output as augment, but runs on the component-repo hook — it fires when the component's repo receives a commit and reads catalog-info.yaml from the checkout instead of the Contents API. No GH_TOKEN required. Use it for near-real-time updates; see Choosing a variant. |
Hook Type
| Cataloger | Hook | Schedule / Trigger | Description |
|---|---|---|---|
augment |
component-cron |
0 3 * * * |
Runs daily at 03:00 UTC, once per existing component |
augment-on-commit |
component-repo |
on commit to a component's repo | Runs whenever a component's repo receives a commit, once per affected component |
Both hooks invoke the cataloger separately for each Lunar component, exposing the component identifier as $LUNAR_COMPONENT_ID. The difference is when and with what: component-cron fires on a schedule with no repo checkout (so augment fetches the file over the API), while component-repo fires on a commit with the component's repo checked out (so augment-on-commit reads the file locally). See the cataloger-hooks reference for the full contract on component-cron and component-repo.
Daily at 03:00 is a conservative default for augment — it's offset by an hour from the standard 0 2 * * * so it lands after component-defining catalogers populate the catalog. Tighten the cadence by overriding hook.schedule in a fork.
Choosing a variant
The two sub-catalogers produce identical catalog entries from the same catalog-info.yaml; pick by how fresh you need the data and whether a checkout is available:
augment (component-cron) |
augment-on-commit (component-repo) |
|
|---|---|---|
| Freshness | Up to a day stale (next cron) | Near-real-time — updates on the triggering commit |
| GitHub token | Required (GH_TOKEN, Contents API) |
Not required (reads the checkout) |
| Catches drift independent of commits | Yes — re-reads every component each cron | No — only fires when a repo is committed to |
| Covers components whose repo is quiet | Yes | No, until the next commit |
They aren't mutually exclusive. Running both is a reasonable default: augment-on-commit keeps active repos current within seconds of a change, while the nightly augment backfills components that haven't seen a commit and re-asserts state. Selecting a subset is done with include / exclude (see Installation). When both run, they write the same keys with the same data, so there's no conflict — the later writer simply re-asserts the value.
Installation
Add to your lunar-config.yml. With no include / exclude, both sub-catalogers run:
catalogers:
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info@v1.0.0
Pick a single variant with include (see Choosing a variant):
catalogers:
# Commit-triggered only — near-real-time, no GitHub token needed
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info@v1.0.0
include: [augment-on-commit]
catalogers:
# Scheduled only — daily backfill via the GitHub Contents API
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info@v1.0.0
include: [augment]
If you run the augment variant (scheduled / Contents API), set the GitHub token used to fetch catalog-info.yaml from each repo:
lunar secret set GH_TOKEN <your-github-token>
The token needs Contents: Read on every repo this cataloger will read (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 any of the GitHub-API collectors, this cataloger picks it up automatically. The augment-on-commit variant reads the file from the component-repo checkout, so it needs no token — if you run only that variant you can skip this step.
Because both variants only augment existing components, a component-defining cataloger must run first (see the Layering section below).
Layering with a Component-Defining Cataloger
Both variants augment existing components only — neither component-cron nor component-repo can create them. Run github-org first so this cataloger has something to augment:
catalogers:
- uses: github.com/earthly/lunar-lib/catalogers/github-org@v1.0.0
with:
org_name: "acme"
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info@v1.0.0
Pick This or the Live Backstage Cataloger — Not Both
The data source is the same Backstage metadata; the difference is where you read it from. Pick one based on whether you run a Backstage server:
Use this cataloger (backstage-catalog-info) when… |
Use the live-API backstage cataloger when… |
|---|---|
You don't run a Backstage server — catalog-info.yaml files in repos are the source of truth |
You run a Backstage instance and want its server-side processing (group hierarchy resolution, namespace defaults, relations) |
| You want repo-file fidelity (whatever is committed is what shows up) | You want a single global pull at fixed cadence against a central API |
Running both would write to the same .components keys with the same data via different paths — wasteful and the last-declared cataloger silently wins. Don't layer them; pick the one that matches your Backstage setup.
Mapping Components to Backstage Entities
A catalog-info.yaml may declare more than one entity (monorepos commonly ship a Component + a System in one file, or several Components for sub-packages). The cataloger picks which Component corresponds to the current Lunar component using two rules:
-
Annotation match (preferred). If any
Componententity in the file has the configured annotation, only annotated entries participate in matching: the cataloger picks the one whosemetadata.annotations[<component_id_annotation>]value, prefixed withcomponent_id_prefix, equals$LUNAR_COMPONENT_ID. Defaults assume the standardgithub.com/project-slugannotation:with: component_id_annotation: "github.com/project-slug" # value: "acme/payment-api" component_id_prefix: "github.com/" # → "github.com/acme/payment-api"If no annotated entry matches, the cataloger skips silently — it refuses to guess for a repo that already uses annotations to disambiguate.
-
Single-Component fallback. If no
Componententity has the annotation and the file contains exactly oneComponent, that entity is used. This covers the common single-Component-per-repo case where the annotation isn't worth maintaining.
If the file has multiple Component entities and none are annotated, the cataloger skips silently — the YAML needs annotations to disambiguate.
Restricting Synced Kinds
This cataloger only processes kind: Component entities. Domain, System, API, Resource, User, Group, Location, etc. are ignored — they're either container-level concepts (handled by a global cataloger like backstage) or not Lunar catalog concerns.
Excluding Components from Augmentation
Two inputs decide which components this cataloger skips, giving platform teams a choice between keeping control and delegating opt-out to dev teams:
-
ignore_components(platform-controlled). A comma-separated list of component ids to skip — matched by exact id or glob (e.g.github.com/acme/legacy-*). A matched component is skipped before itscatalog-info.yamlis even fetched. Because the list lives inlunar-config.yml, dev teams can't override it — this is the "platform team keeps control" path.with: ignore_components: "github.com/acme/sandbox,github.com/acme/legacy-*" -
lunar.io/ignoreannotation (dev-delegated, gated). Whenallow_ignore_annotation: true, any component whose matchedComponentcarrieslunar.io/ignore: "true"(valuestrue/yes/1; key configurable viaignore_annotation) opts itself out of augmentation. This is the "platform team delegates opt-out to dev teams" path. It's off by default — until you enable the gate, the annotation is ignored and exclusion stays entirely platform-controlled.with: allow_ignore_annotation: true # let repos self-exclude via lunar.io/ignore
The two compose: ignore_components is always enforced; the annotation only when its gate is on. Use the list alone for hard central control, the gate alone (or both) to let dev teams remove their own components.
Sourcing the Domain from a Custom Annotation
Some orgs model component domains via a custom annotation rather than the canonical Backstage spec.domain field — for example, to express a hierarchical name like engineering.tooling.observability that Backstage's flat spec.domain doesn't model well. Set domain_annotation to that key and the cataloger reads it from metadata.annotations[<key>]:
catalogers:
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info@v1.0.0
with:
domain_annotation: "yourorg.example.com/domain"
When set and the matched Component has that annotation, its value wins over spec.domain / spec.system. When the annotation is absent on a given entity, the cataloger falls back to spec.domain then spec.system as usual. Leave domain_annotation empty (the default) to use only the canonical Backstage fields.
Default Domain
Not every catalog-info.yaml sets a domain. When a matched Component resolves to no domain at all — no domain_annotation value, no spec.domain, and no spec.system — the cataloger leaves the component's domain unset by default. Set default_domain to assign a fallback instead:
catalogers:
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info@v1.0.0
with:
default_domain: "engineering"
The value is written verbatim, and a stub .domains["<default_domain>"] entry is written alongside it so the hub's domain validation accepts the reference (the same stub-write the cataloger does for any other domain). default_domain is purely a last-resort fallback — it never overrides a domain that the file already provides through any of the sources above. Leave it empty (the default) to keep domain-less components unset.
This mirrors default_owner for ownership: use it to funnel otherwise-uncategorized repos into a sensible default domain rather than leaving them blank.
Mapping Annotations to Component Meta (PagerDuty and others)
Several lunar-lib collectors resolve their per-component target from the Lunar component meta field rather than from config — for example, the pagerduty collector reads pagerduty/service-id from LUNAR_COMPONENT_META to know which PagerDuty service to query. That meta value has to come from somewhere; this cataloger sources it from a catalog-info.yaml annotation.
meta_annotations is a comma-separated list of <annotation-key>=<meta-key> pairs. For each pair, if the matched Component carries that annotation, its value is written to .components["$LUNAR_COMPONENT_ID"].meta[<meta-key>]. The default maps the annotation PagerDuty's Backstage integration guide recommends:
catalogers:
- uses: github.com/earthly/lunar-lib/catalogers/backstage-catalog-info@v1.0.0
# default: meta_annotations: "pagerduty.com/service-id=pagerduty/service-id"
So a repo whose catalog-info.yaml has:
metadata:
annotations:
pagerduty.com/service-id: PABC123
gets meta: {"pagerduty/service-id": "PABC123"}, and the pagerduty collector (and the oncall guardrails behind it) works with no further per-component config. Note the annotation namespace is pagerduty.com/ (the Backstage/PagerDuty convention) while the Lunar meta key is pagerduty/service-id — the mapping bridges the two.
Add pairs to feed other collectors:
with:
meta_annotations: "pagerduty.com/service-id=pagerduty/service-id,sonarqube.io/project-key=sonarqube/project-key"
Whitespace around each pair and its = is trimmed. Set meta_annotations to empty to write no meta at all. A pair whose annotation is absent on a given component is skipped, and .meta is omitted entirely when nothing matches (so it never clobbers meta set elsewhere with an empty object).
Owner Format
Backstage spec.owner is typically an entity reference like group:default/team-payments or user:default/jane, not an email. By default this cataloger passes the value through verbatim — matching what the existing policies/backstage/owner-set policy already accepts (team-payments, group:infra, user:alice are all valid).
If you'd rather store bare names, set owner_format: bare-name to strip the <kind>:<namespace>/ prefix. default_owner is also written verbatim, regardless of owner_format.
Source System
GitHub — the cataloger calls the Contents API once per component invocation to fetch catalog-info.yaml from each repo. Requirements:
GH_TOKENsecret with read access to every repo this cataloger will read (Contents: Readon a fine-grained PAT,repoon a classic PAT, orcontents: readon a GitHub App installation token).- Component IDs match
<component_id_prefix><owner>/<repo>(defaultgithub.com/<owner>/<repo>). Non-GitHub component IDs are skipped silently — this cataloger is GitHub-specific.
The cataloger makes no other external calls. YAML parsing and entity selection happen in-process; the only outbound traffic is the GitHub fetch.
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.