Hamburger Cross Icon
Backstage catalog-info.yaml Cataloger - Lunar Cataloger

Backstage catalog-info.yaml Cataloger

Cataloger Beta Service CatalogVcs

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.

Add 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.

Cataloger component-cron

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.

Schedule: 0 3 * * *
service catalog backstage catalog-info components ownership augment
Book a demo
Cataloger component-repo

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.

service catalog backstage catalog-info components ownership augment commit-triggered real-time
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": {
    "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 GitHub

Backstage 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:

  1. Annotation match (preferred). If any Component entity in the file has the configured annotation, only annotated entries participate in matching: the cataloger picks the one whose metadata.annotations[<component_id_annotation>] value, prefixed with component_id_prefix, equals $LUNAR_COMPONENT_ID. Defaults assume the standard github.com/project-slug annotation:

    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.

  2. Single-Component fallback. If no Component entity has the annotation and the file contains exactly one Component, 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 its catalog-info.yaml is even fetched. Because the list lives in lunar-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/ignore annotation (dev-delegated, gated). When allow_ignore_annotation: true, any component whose matched Component carries lunar.io/ignore: "true" (values true / yes / 1; key configurable via ignore_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_TOKEN secret with read access to every repo this cataloger will read (Contents: Read on a fine-grained PAT, repo on a classic PAT, or contents: read on a GitHub App installation token).
  • Component IDs match <component_id_prefix><owner>/<repo> (default github.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.

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
Automate Now
Paste your AGENTS.md or manual process doc and get guardrails in minutes
Book a Demo