# Introduction

Earthly Lunar is a guardrails platform for monitoring and enforcing engineering practices across your software development lifecycle.

Lunar works by instrumenting your existing CI/CD pipelines (no YAML changes needed) and source code repositories to collect structured metadata about how code is built, tested, scanned, and deployed. This metadata is then continuously evaluated against policies that you define—policies that are flexible, testable, and expressive enough to reflect your real-world engineering standards.

Want to block deployments that would violate compliance rules, like using unapproved licenses or bypassing required security scans? Or fail a PR if it introduces stale dependencies or vulnerable CI plugins? Or ensure that security-sensitive services are collecting SBOMs, running code scans, and deploying frequently enough to avoid operational drift? Lunar makes all of that possible—without requiring a wholesale rewrite of every team's CI pipeline, and without sacrificing developer velocity.

Lunar is designed to work with the messy reality of modern engineering. It's not a one-size-fits-all template. Its instrumentation is flexible and centralized—meaning platform teams stay in control, app teams stay autonomous, and standards actually get enforced.

## Overview

Earthly Lunar helps organizations maintain high engineering standards by:

* Monitoring components (services, libraries, repositories) across your organization
* Collecting metadata about your software development lifecycle
* Enforcing policies and best practices
* Providing visibility into engineering health through checks

## Key Features

* **Component Monitoring**: Track the health and status of individual software components
* **SDLC Instrumentation**: Collect data from various stages of your software development lifecycle
* **Policy Enforcement**: Define and enforce engineering standards across your organization
* [**200+ Pre-Built Guardrails**](https://earthly.dev/lunar/guardrails/): Ready-to-use policies for testing, security, compliance, and operational readiness
* [**50+ Integrations**](https://earthly.dev/lunar/integrations/): Connect to GitHub, Kubernetes, Docker, Codecov, Snyk, and more
* **Extensible Platform**: Create custom collectors and policies using Bash or Python SDKs
* **SQL API**: Query and analyze your engineering data

## AI Skills

To get started quickly with developing guardrails, see the [AI skills](/install/skills) compatible with Claude Code, Codex, and Cursor.

## Getting Started

1. [Install Lunar](/install)
2. [Learn the basics](/basics)
3. [Understand key concepts](/docs/key-concepts)


# Install Lunar

Overview of Lunar installation, covering Hub, CLI, CI Tracer for self-hosted and managed runners, and AI skills for building plugins.

Welcome to the Lunar installation guide.

This section contains step-by-step instructions for installing Lunar. Install these pieces, in this order:

1. **Lunar CLI** – an admin and development CLI. Install this first: the Hub install walkthrough uses `lunar licence` to derive your GHCR image-pull secret from your licence JWT before `helm install`.
   * [Install the Lunar CLI](/install/cli)
2. **Lunar Hub** – the central coordination service. Decide how you will run it before the next step, because the Git platform setup differs between the two.
   * **Self hosted** – you operate the Hub on your own Kubernetes:
     * [Overview](/install/lunar-hub/self-hosted/overview) – the lay of the land: services, dependencies, ports.
     * [Prerequisites](/install/lunar-hub/self-hosted/prerequisites) – what to have in place before `helm install`.
     * [Install walkthrough](/install/lunar-hub/self-hosted/install-walkthrough) – step-by-step from zero to a working Hub.
     * [Day-2 operations](/install/lunar-hub/self-hosted/day-2-operations) – upgrades, secret rotation, observability, uninstall.
   * [**Lunar Dedicated**](/install/lunar-hub/dedicated) – Earthly provisions, operates, and upgrades a single-tenant install in a dedicated AWS account:
     * [Overview](/install/lunar-hub/dedicated/overview) – how it works, how you reach it, and how it handles your data.
     * [Setup](/install/lunar-hub/dedicated/setup) – what your side does: the questionnaire, the depositor account, and the secret deposit.
     * [PrivateLink to your hub](/install/lunar-hub/dedicated/privatelink-inbound) – the recommended way for your CI and browsers to reach the install, where your CI runs in a network you control.
     * [PrivateLink to your systems](/install/lunar-hub/dedicated/privatelink-outbound) – only if Lunar's collectors must reach systems that aren't on the public internet. Longest lead time in onboarding, so start it first.
3. **Git platform** – the credential the Hub reads repositories and posts results with. The Hub will not start without one, so create it before you finish the install above: before `helm install` when self-hosted, or before kickoff on Dedicated.
   * [GitHub](/install/git-platforms/github) for a GitHub App on your org, or [GitLab](/install/git-platforms/gitlab) for a group access token per top-level group.
   * [Git Platforms](/install/git-platforms) – what differs between the two, if you are choosing or running both.
4. **CI integration** – how Lunar gets build-time data, via the [Lunar CI Tracer](/install/ci-tracer). This step depends on your CI:
   * **GitHub Actions** – install the tracer on [self-hosted runners](/install/ci-tracer/github-actions-self-hosted), or use the [action](/install/ci-tracer/github-actions-managed) on GitHub-hosted ones.
   * **Buildkite** – an agent hook plus a webhook; see [Buildkite](/install/ci-tracer/buildkite).
   * **GitLab CI** – nothing to install; tracing support is [coming soon](/install/ci-tracer/gitlab-ci). Lunar collects from source events instead — see [GitLab](/install/git-platforms/gitlab).
5. **Sync Config** – strongly recommended. Push your config repo to Lunar Hub on every push, via the GitHub Action or a GitLab CI job, so the Hub's copy of your configuration never drifts from what's in git.
   * [Set up config sync](/install/lunar-hub/self-hosted/sync-config)

Optional:

* [AI Skills](/install/skills) – agent skills for building collectors and policies.

Want to try Lunar without installing anything? Get in touch for a guided demo or preview.

<a href="https://earthly.dev/earthly-lunar/demo" class="button primary" data-icon="calendar">Request a demo</a>

Before diving in, browse the [200+ pre-built guardrails](https://earthly.dev/lunar/guardrails/) and [60+ integrations](https://earthly.dev/lunar/integrations/) available out of the box.


# Lunar CLI

Install the Lunar CLI for managing configurations, inspecting components, running collectors, and testing policies locally.

The Lunar CLI is primarily an administration tool for platform engineers, with developer-focused capabilities secondarily. It uses the same `lunar` binary as the CI tracer but provides different subcommands for interactive CLI usage versus CI instrumentation. It can be used to manage configurations, inspect components, run collectors, and test policies.

## Prerequisites

**Docker** is required for local development commands since collectors and policies run inside containers.

{% hint style="info" %}
**macOS Users:** If using Docker Desktop, you must enable the default Docker socket. Go to **Docker Desktop → Settings → Advanced** and enable **"Allow the default Docker socket to be used"**, then click **Apply & Restart**.
{% endhint %}

## Installation

{% stepper %}
{% step %}

## Download the binary

Download the build for your operating system and CPU from the [releases page](https://github.com/earthly/lunar-dist/releases/latest).

<a href="https://github.com/earthly/lunar-dist/releases/latest" class="button primary" data-icon="download">Download the Lunar CLI</a>
{% endstep %}

{% step %}

## Move the binary into place

Create the install directory, make the binary you downloaded executable, and move it there as `lunar`.

{% tabs %}
{% tab title="macOS (Apple Silicon)" %}

```bash
mkdir -p "$HOME/.lunar/bin" && chmod +x lunar-darwin-arm64 && mv ./lunar-darwin-arm64 "$HOME/.lunar/bin/lunar"
```

{% endtab %}

{% tab title="Linux (x86-64)" %}

```bash
mkdir -p "$HOME/.lunar/bin" && chmod +x lunar-linux-amd64 && mv ./lunar-linux-amd64 "$HOME/.lunar/bin/lunar"
```

{% endtab %}

{% tab title="Linux (ARM64)" %}

```bash
mkdir -p "$HOME/.lunar/bin" && chmod +x lunar-linux-arm64 && mv ./lunar-linux-arm64 "$HOME/.lunar/bin/lunar"
```

{% endtab %}
{% endtabs %}

{% hint style="info" %}
**On Windows, use WSL2.** Lunar's CLI ships as Linux and macOS builds only, so on Windows you install it inside a WSL2 (Ubuntu) shell and follow the Linux steps above. Docker Desktop already runs on a WSL2 backend, so this is the supported Windows path.
{% endhint %}

If you prefer to install `lunar` to a different directory, set the `LUNAR_BIN_DIR` environment variable to that path (see below) and `mkdir -p` it instead.
{% endstep %}

{% step %}

## Set environment variables

```bash
# Required
export LUNAR_HUB_TOKEN=your_hub_token
export LUNAR_HUB_HOST=your_hub_host
export LUNAR_HUB_GRPC_PORT=your_grpc_port
export LUNAR_HUB_HTTP_PORT=your_http_port
export PATH="$HOME/.lunar/bin:$PATH"

# Optional, if you want to override the default bin dir
export LUNAR_BIN_DIR="$HOME/.lunar/bin"
```

{% endstep %}

{% step %}

## Verify installation

```bash
lunar --help
```

{% endstep %}
{% endstepper %}

For usage examples and full CLI documentation, see the [Lunar CLI Docs](/docs/lunar-cli).

***

## Next Steps

Once installed, you can begin configuring:

* [Collectors](/configuration/lunar-config/collectors) to gather SDLC data
* [Policies](/configuration/lunar-config/policies) to enforce standards
* [Domains and Components](/docs/key-concepts) to organize your software landscape

For questions or enterprise onboarding:

<a href="https://earthly.dev/earthly-lunar/demo" class="button secondary" data-icon="envelope">Contact the Earthly team</a>


# Lunar Hub

Install and configure Lunar Hub, the central service that stores metadata, evaluates policies, and provides visibility into engineering health.


# Self hosted

Run Lunar Hub yourself on Kubernetes — the overview, prerequisites, install walkthrough, config sync, and day-2 operations, in the order you need them.

Self hosting means you run Lunar Hub on your own Kubernetes cluster and operate it: you provision its dependencies, run `helm install`, and own upgrades, secret rotation, and backups. If you would rather Earthly did all of that, see [Lunar Dedicated](/install/lunar-hub/dedicated) instead.

{% hint style="info" %}
Lunar Hub is supported on **Kubernetes only**. Bare-metal and Docker installations are not supported.
{% endhint %}

## In order

1. [**Overview**](/install/lunar-hub/self-hosted/overview) — what a Hub deployment is made of: the Hub service, the operator, the transient snippet pods, and the external dependencies they need. Read this first if you want the shape of the system before committing to it.
2. [**Prerequisites**](/install/lunar-hub/self-hosted/prerequisites) — Postgres, S3 buckets, namespaces, DNS, a licence, and your Git platform credentials. Everything that must exist before `helm install`.
3. [**Install walkthrough**](/install/lunar-hub/self-hosted/install-walkthrough) — step by step from zero to a working Hub, including the values file and how to verify the install.
4. [**Sync Config**](/install/lunar-hub/self-hosted/sync-config) — keep the Hub's copy of your configuration current on every push, from a GitHub Action or a GitLab CI job. Strongly recommended: without it you apply config changes by hand.
5. [**Day-2 operations**](/install/lunar-hub/self-hosted/day-2-operations) — upgrades, secret rotation, observability, backup and restore, and uninstall.

## Before you start

Two neighboring sections apply to Dedicated installs as well, so they sit outside this one:

* [**Git Platforms**](/install/git-platforms) covers creating the credential the Hub authenticates with, on GitHub or GitLab. You need it by the prerequisites step.
* [**Lunar CI Tracer**](/install/ci-tracer) covers build-time data, if you want it. The tracer installs on your CI runners rather than in the cluster, so it is independent of the Hub install and can be added later.


# Overview

This page is a high-level overview of what a Lunar Hub deployment looks like on Kubernetes. It's meant to provide a conceptual overview of the system before you dive into the installation process. When you're ready to install, start by setting up the [prerequisites](/install/lunar-hub/self-hosted/prerequisites).

## What you're installing

A Lunar deployment primarily consists of:

* the Lunar Hub (a Kubernetes service)
* the Lunar Operator (a Kubernetes controller)
* a fleet of transient policy/cataloger/collector batches managed by the operator
* and three external dependencies you provide (PostgreSQL, S3, and credentials for your Git platform — a GitHub App or a GitLab group access token)

### External boundaries

```mermaid
flowchart LR
  CLI["Lunar CLI"]
  Agent["Lunar CI Tracer"]

  Hub["Lunar Hub"]

  PG[("PostgreSQL")]
  S3[("S3 buckets<br/>logs · resources")]
  Git["GitHub or GitLab"]

  CLI -- gRPC --> Hub
  Agent -- gRPC --> Hub
  Git -- webhooks --> Hub

  Hub -- SQL --> PG
  Hub -- reads/writes --> S3
  Hub -- API --> Git
```

#### Hub

This is the central API server. It:

* talks to Postgres, your Git platform, and S3
* serves the gRPC API consumed by the CLI, CI tracers, and the Lunar operator
* receives Git platform webhooks on its HTTP port
* issues pre-signed S3 URLs for bulk data (run resources, run logs) transferred between work units orchestrated by the operator

For more details on how this hooks into the rest of the system, see [Ports and protocols](#ports-and-protocols) below.

#### Lunar CLI

The `lunar` binary. Used by platform engineers to push configuration, inspect components, and run collectors/policies locally. [This is installed separately.](/install/cli).

#### Lunar CI Tracer

Instruments your CI runners to report data to the Hub. This is also installed separately. We support [Self-hosted GitHub Actions](/install/ci-tracer/github-actions-self-hosted) and [Managed GitHub Actions](/install/ci-tracer/github-actions-managed) options, plus [Buildkite](/install/ci-tracer/buildkite). GitLab CI tracing is [coming soon](/install/ci-tracer/gitlab-ci) — see [GitLab](/install/git-platforms/gitlab) for how collection works there today.

### Inside the cluster

```mermaid
flowchart LR
  subgraph K8s["Kubernetes cluster"]
    subgraph CP["Control-plane namespace"]
      Hub["Lunar Hub"]
      Op["Lunar Operator"]
    end
    subgraph RP["Run-pods namespace"]
      SP["Run Pods<br/>(init + sidecar + user code)"]
    end
    Op -- creates --> SP
  end

  PG[("PostgreSQL")]

  Hub -- SQL --> PG
  Op -- polls queue --> PG
  SP -- gRPC --> Hub
```

#### Lunar Operator

A Kubernetes controller that groups enqueued runs into batches and materializes each batch as a short-lived Kubernetes pod. It manages the whole lifecycle of these pods, from creation to cleanup.

#### Run pods

Short-lived batch pods. The init container fetches needed data from S3 and your Git platform and coordinates the user containers. User containers execute the specified cataloger, collector, or policy. The sidecar streams each container's logs to S3 and reports exit codes back to the Hub over gRPC.

Splitting the control plane (Hub + operator) and run pods into separate namespaces is recommended. This provides different blast radius, different resource profile, and different RBAC for distinctly different pieces of the system. Single-namespace installs also work; see [Step 1](/install/lunar-hub/self-hosted/prerequisites#step-1-plan-your-kubernetes-namespaces) of the prerequisites for details.

### Grafana

Grafana is the primary UI for Lunar today — dashboards for policy results, component health, and collection activity, reading from the same Postgres database as the Hub. Lunar ships those dashboards (plus the datasources and panel plugins they need) and **installs them into a target Grafana over its HTTP API**. You pick which Grafana based on what you already run:

| Your situation                                         | How Lunar reaches Grafana                                                                                                                                                          |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No Grafana of your own** — you want Lunar to run one | The chart deploys a stock Grafana alongside the Hub and installs the dashboards into it for you. Enabled by default; admin credentials are auto-generated.                         |
| **Grafana Cloud or Grafana Enterprise**                | Point Lunar at your Grafana and give it a **service-account token**.                                                                                                               |
| **Self-hosted OSS Grafana**                            | Point Lunar at your Grafana and give it **admin username + password** (OSS Grafana needs a server admin to install the dashboards' panel plugins — a service-account token can't). |

In every case the chart provisions the dashboards for you on `helm install` / `helm upgrade` via a post-install/post-upgrade hook Job (`grafana.provisioning`).

[Install Step 4](/install/lunar-hub/self-hosted/install-walkthrough#grafana) walks through all three. To turn Grafana off entirely, set `grafana.mode: off`; full configuration is in the [chart README](https://github.com/earthly/charts/blob/main/README.md#grafana).

## Networking

### Connectivity

Use this table when planning ingress rules, egress allowlists, and NetworkPolicies. Specific Hub ports are detailed in the [next section](#ports-and-protocols).

| Source           | Destination         | Direction  | Purpose                                   |
| ---------------- | ------------------- | ---------- | ----------------------------------------- |
| GitHub / GitLab  | Hub HTTP ingress    | Inbound    | Webhook delivery                          |
| CLI / CI tracers | Hub gRPC ingress    | Inbound    | API calls (config sync, results)          |
| CLI / CI tracers | Hub HTTP ingress    | Inbound    | Pre-signed log URL fetches                |
| Hub              | GitHub / GitLab API | Outbound   | Read repos, post results, manage webhooks |
| Hub              | Postgres            | Outbound   | Hub state (incl. work queue, migrations)  |
| Hub              | S3                  | Outbound   | Run pod resource uploads                  |
| Operator         | Postgres            | Outbound   | Poll work queue (own schema)              |
| Run pods         | S3                  | Outbound   | Upload logs, download resources           |
| Run pods         | Hub Service         | In-cluster | Exit codes (gRPC); log URL fetches (HTTP) |

### Ports and protocols

The Hub listens on three ports inside the pod. Only two are exposed externally; the third is for in-cluster health probes.

| Port   | Protocol | Purpose                                                    | Who talks to it                                        |
| ------ | -------- | ---------------------------------------------------------- | ------------------------------------------------------ |
| `8000` | gRPC     | API — config sync, policy evaluation, run results          | CLI, CI tracers, run pods (sidecar)                    |
| `8001` | HTTP     | Webhook receivers + pre-signed URL redirector for run logs | Your Git platform, Buildkite, CLI, CI tracers, sidecar |
| `8002` | HTTP     | Liveness / readiness probes (`GET /health`, `GET /ready`)  | Kubelet only                                           |

The HTTP port serves webhook ingestion — `/webhooks/github`, `/webhooks/gitlab`, and `/webhooks/buildkite` — plus `/logs/runs/`, which redirects to pre-signed S3 URLs for log upload and download. No other HTTP routes exist. The Hub does not currently expose Prometheus metrics; observability is via OpenTelemetry (OTLP).

## Next steps

* [Prerequisites](/install/lunar-hub/self-hosted/prerequisites) — external dependencies you need in place before `helm install`.
* [Install walkthrough](/install/lunar-hub/self-hosted/install-walkthrough) — step-by-step from zero to a working Hub.


# Prerequisites

Before you run `helm install lunar`, the external dependencies below must be in place. The [install walkthrough](/install/lunar-hub/self-hosted/install-walkthrough) assumes you have them.

If you want a total picture of what you're about to deploy, read the [overview](/install/lunar-hub/self-hosted/overview) first.

{% hint style="info" %}
Lunar Hub is supported on Kubernetes only. Bare-metal and Docker installations are not supported.
{% endhint %}

{% hint style="info" %}
**Install the** [**Lunar CLI**](/install/cli) **first.** The walkthrough uses it on your workstation to derive the cluster's GHCR image-pull secret from your licence — before `helm install`.
{% endhint %}

## Before you begin

Make sure you have the following available before continuing.

| Step                                                | What you need             | Detail                                                                                                                                                                                                         |
| --------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| —                                                   | Workstation tools         | [`lunar`](/install/cli), `kubectl`, `helm` 3.x                                                                                                                                                                 |
| [2](#step-2-check-your-kubernetes-cluster)          | Kubernetes cluster        | 1.29+, with an ingress controller and a default StorageClass                                                                                                                                                   |
| [2](#step-2-check-your-kubernetes-cluster)          | DNS hostnames + TLS certs | One hostname for the Hub (reachable from your Git platform — github.com, GHES, gitlab.com, or your GitLab instance), plus a second for Grafana if you let the chart run it for you (reachable from your users) |
| [3](#step-3-provision-postgresql)                   | PostgreSQL instance       | 16+, where you can create a dedicated owner role                                                                                                                                                               |
| [4](#step-4-provision-s3-compatible-object-storage) | S3-compatible buckets     | Two private buckets (logs + resources) with IAM to read and write them                                                                                                                                         |
| [5](#step-5-connect-your-git-platform)              | Git platform admin access | GitHub org admin (or a personal account) to create a GitHub App, and/or GitLab group Maintainer/Owner to create a group access token                                                                           |
| [6](#step-6-plan-your-kubernetes-secrets)           | Hub license key           | Provided by Earthly; mounted by the chart as `lunar-hub-licence`                                                                                                                                               |

{% hint style="info" %}
If you're standing up EKS from scratch, [`earthly/lunar-terraform-quickstart`](https://github.com/earthly/lunar-terraform-quickstart) is a working reference module you can fork if desired.
{% endhint %}

## Step 1 — Plan your Kubernetes namespaces

We recommend splitting the install across two namespaces:

* **Control-plane namespace** (e.g. `lunar`). This is the release namespace. It hosts the Lunar Hub, the Operator, and — if you use the chart's bundled Grafana — Grafana. These are the parts that need to stay up, and will be updated by Helm.
* **Run Pods namespace** (e.g. `lunar-scripts`). This hosts the short-lived pods the operator spawns to execute cataloger, collector, and policy batches. We recommend this separate namespace because:
  * this workload is ephemeral, and can be rather "bursty" in number and resource requirements. You can tune resources and limits independently here.
  * this code is user-supplied (e.g. your plugins, scripts, third-party catalogers), not Lunar's. This is a different trust boundary, where you can tighten RBAC, egress, and resource limits independently.

Both namespaces must exist **before** `helm install`. The chart will not create the run-pods namespace for you. Point the operator at it with `operator.snippetNamespace`.

Single-namespace installs also work — leave `operator.snippetNamespace` unset and everything runs in the release namespace. This is fine for trying things out, or small setups; but is *not recommended* for production configurations.

```bash
kubectl create namespace lunar
kubectl create namespace lunar-scripts
```

## Step 2 — Check your Kubernetes cluster

| Requirement            | Detail                                                                                                                                                                                                                                                                                                                                                                    |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Kubernetes**         | 1.29 or newer.                                                                                                                                                                                                                                                                                                                                                            |
| **Helm**               | 3.x.                                                                                                                                                                                                                                                                                                                                                                      |
| **StorageClass**       | A StorageClass must be available — your cluster default is probably fine. The chart provisions a 10 GiB `ReadWriteOnce` PVC for Hub state. You can tune details if needed via `hub.persistence.*` ([chart README](https://github.com/earthly/charts/blob/main/README.md)).                                                                                                |
| **Ingress controller** | Must support gRPC backend routing. See [Ingress](https://github.com/earthly/charts/blob/main/README.md#ingress) in the chart README for an NGINX-tested example.                                                                                                                                                                                                          |
| **DNS**                | A hostname for the Hub (e.g. `lunar.example.com`) pointing at your ingress controller's external IP — your Git platform, CI integrations, and Lunar CLI users must reach it. If you let the chart run Grafana for you, add a second hostname for it (e.g. `grafana.lunar.example.com`) for your team's browser access; not needed if you point Lunar at your own Grafana. |
| **TLS certificate**    | The Hub (and the bundled Grafana, when enabled) listen plaintext. Terminate TLS at your ingress or an upstream load balancer for each hostname.                                                                                                                                                                                                                           |

## Step 3 — Provision PostgreSQL

Lunar needs a single PostgreSQL database. Schema migrations run as a pre-rollout Job on each release, and the Hub manages several of its own schemas (including the default `public`). We recommend that you give it a dedicated DB where its role is the owner.

| Requirement         | Detail                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Version**         | PostgreSQL 16 or newer.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| **Connectivity**    | Reachable from both the Hub and Operator pod's network. The chart does **not** include Postgres.                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| **Role**            | Dedicated DB role for the Hub. Minimum: **database owner** (to create schemas, and grants on created objects) **plus cluster-level `CREATEROLE`** (to create the read-only `sqlapi_user` and `grafana_user` roles during migration). `SUPERUSER` also works.                                                                                                                                                                                                                                                                         |
| **Extensions**      | Optional: `pg_stat_statements` enabled in `shared_preload_libraries`. The Hub's diagnostics bundle uses it when present. Setting `shared_preload_libraries` requires a Postgres restart (or parameter-group reboot on RDS / Cloud SQL), so it's easier to enable at provisioning time than later.                                                                                                                                                                                                                                    |
| **Connection pool** | The Hub is stateless and runs as multiple replicas; each opens \~85 connections by default (`maxOpenConns + maxPoolConns + operatorPoolSize`), so total connections scale with `hub.replicaCount`. Size your Postgres (or PgBouncer) for `replicaCount × per-replica pool`. See [Scaling](/install/lunar-hub/self-hosted/day-2-operations#scaling) in Day 2 Operations for tuning context.                                                                                                                                           |
| **SSL**             | The Hub negotiates TLS by default via `hub.db.connectionOptions: "sslmode=require"`. Most managed Postgres (RDS, Aurora, Cloud SQL) ships with TLS forced and will connect out of the box. Plain Postgres deployments without TLS must set `hub.db.connectionOptions: "sslmode=disable"` explicitly. **Format is libpq KV pairs, space-separated** — to pass extra options write `"sslmode=require connect_timeout=10"` (NOT `&`-separated URL query). The default is not merged in when overridden, so include `sslmode=` yourself. |
| **Backups**         | Use your existing Postgres backup process; backups are your responsibility. All authoritative Hub and Operator state lives entirely in Postgres.                                                                                                                                                                                                                                                                                                                                                                                     |

{% hint style="info" %}
**Shared Postgres cluster?** Because `CREATEROLE` is a cluster-level attribute, Postgres has no mechanism to scope it to a single database. If granting it cluster-wide is too broad, pre-create the `sqlapi_user` role yourself (any password) and leave `HUB_SQLAPI_PASSWORD` unset. The migration's `IF NOT EXISTS` check skips role creation, leaving only schema-level grants — which the Hub's role can do as database owner.
{% endhint %}

## Step 4 — Provision S3-compatible object storage

Lunar needs two private S3 buckets, both writable by the Hub:

* A **Logs bucket**, which contains per-run log files. These can be short-lived; a 30-day lifecycle rule is reasonable here.
* A **Resources bucket**, which contains run-bundle archives fetched by init containers. Keep these as long as you might re-run historical catalogers, collectors, or policies.

Both buckets must block public access. Content may include credentials, user script source code, or PII surfaced from CI runs. The Hub serves all reads via time-limited pre-signed URLs.

{% hint style="warning" %}
**Object lifecycles are your responsibility.** Lunar never deletes from either bucket. Set S3 lifecycle rules yourself to cap storage growth. Check with your compliance requirements before settling on retention windows, since logs and run bundles may contain information that falls under your organization's data-retention policies.
{% endhint %}

The Hub *only* calls `PutObject`, `GetObject`, and `HeadObject` on both buckets, and issues pre-signed `GET`/`PUT` URLs for both. Minimum IAM policy on AWS:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": [
        "arn:aws:s3:::your-logs-bucket/*",
        "arn:aws:s3:::your-resources-bucket/*"
      ]
    }
  ]
}
```

### Region and credentials

The Hub picks up AWS credentials via the standard [SDK credential chain](https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html) — the chart stays out of the credentials business. Region must be set explicitly:

```yaml
hub:
  extraEnv:
    - name: AWS_REGION
      value: us-east-1
```

Common patterns (see the [chart README](https://github.com/earthly/charts/blob/main/README.md#object-storage--aws-credentials) for full YAML):

* **EKS**. Annotate the chart's service account with an [IAM role for service accounts](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html).
* **Static credentials**. Inject `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` from a secret via `hub.extraEnv`. Not recommended for production.
* **IMDS, pod identity, external secrets operators**. All the usual AWS-SDK-friendly mechanisms work.

### Non-AWS backends

MinIO, Cloudflare R2, and GCS in S3-compatibility mode all work — set `AWS_ENDPOINT_URL_S3` via `hub.extraEnv`:

```yaml
hub:
  extraEnv:
    - name: AWS_REGION
      value: auto
    - name: AWS_ENDPOINT_URL_S3
      value: https://your-minio.example.com
```

Lunar uses virtual-host-style S3 addressing only. Most MinIO, R2, and GCS deployments handle this out of the box. Path-style-only backends aren't currently supported.

## Step 5 — Connect your Git platform

Lunar needs credentials for the Git platform your components live on. At least one is required: the Hub refuses to start with neither configured. Set up both if you run both.

{% hint style="info" %}
Read [Git Platforms](/install/git-platforms) before you start. Most of Lunar behaves identically on GitHub and GitLab; merge gating is one exception.
{% endhint %}

{% tabs %}
{% tab title="GitHub" %}
The Hub authenticates as a **GitHub App** you create and install on your org. The hosted setup tool creates one in a couple of clicks:

1. Visit [**earthly.dev/lunar/github-app-setup**](https://earthly.dev/lunar/github-app-setup/).
2. Follow the prompts. The tool uses GitHub's [manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) to register the App with the right permissions and events.
3. **Download the PEM private key when prompted.** GitHub shows it exactly once — if you click past this page, you'll have to generate a new key from the App settings later.
4. Click *Install App on GitHub* and select the org. Choose **All repositories** unless you have a specific reason not to — Lunar's actual monitoring scope is configured in `lunar-config.yml`, so a narrower scope here just means coming back to **Org Settings → GitHub Apps → Lunar → Repository access** every time you add a new repo to Lunar.

The hosted tool proxies the manifest exchange to GitHub and returns the credentials to your browser; we never persist them.

{% hint style="info" %}
**Need to create the App by hand?** On GitHub Enterprise Server, or when `earthly.dev` is unreachable from your browser, follow [manual setup](/install/git-platforms/github#manual-setup-alternative), which produces the same App. The [permissions and events it is granted](/install/git-platforms/github#permissions) are documented for security review whichever flow you use.
{% endhint %}

Capture these four before continuing:

| What                                                                   | Source                                                                                                                     |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Owner (the GitHub org or user the App is installed on, e.g. `earthly`) | The `<org>` in the install URL                                                                                             |
| App ID (numeric, e.g. `3635822`)                                       | Setup tool result page, or the App settings page                                                                           |
| Installation ID (numeric)                                              | The trailing number in the URL after install: `https://github.com/organizations/<org>/settings/installations/<INSTALL_ID>` |
| PEM private key                                                        | Downloaded when the key was generated. GitHub shows it once, so save it now                                                |

They become `HUB_GITHUB_APP_OWNER`, `HUB_GITHUB_APP_ID`, `HUB_GITHUB_APP_INSTALL_ID`, and `HUB_GITHUB_APP_PRIVATE_KEY` (or the chart equivalents under `hub.github.app.*`) in [install Step 4](/install/lunar-hub/self-hosted/install-walkthrough#git-platform-credentials). All four are required together; a partial App configuration is rejected at startup.

Fronting several orgs from one Hub takes a different shape. See [multiple organizations](/install/git-platforms/github#multiple-organizations).
{% endtab %}

{% tab title="GitLab" %}
GitLab has no App model, so the Hub authenticates with a **service account token**: one for the whole instance on self-managed, or one per top-level group on GitLab.com, each covering every subgroup and project beneath it. [GitLab → Create the token](/install/git-platforms/gitlab#create-the-token) walks through creating it, and through the one choice that has consequences: which account it belongs to.

Capture these before continuing:

| What         | Detail                                                                                                                           |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| Access token | `api` scope, `Maintainer` or `Owner` role in every group Lunar should serve                                                      |
| Group path   | Only for a group service account: the top-level group it belongs to, e.g. `acme`. An instance service account's token needs none |
| Host         | `gitlab.com`, or your instance hostname if self-managed                                                                          |

They become one `HUB_GITLAB_TOKENS` entry in [install Step 4](/install/lunar-hub/self-hosted/install-walkthrough#git-platform-credentials). Serving several top-level groups from one Hub? See [multiple groups](/install/git-platforms/gitlab#multiple-groups).

{% hint style="warning" %}
**Self-managed GitLab must be listed by host.** Lunar decides whether a host is GitLab from this configuration, so note your instance hostname now. An omitted host is treated as GitHub, and every operation against it then fails confusingly. `gitlab.com` is recognized without configuration.
{% endhint %}
{% endtab %}
{% endtabs %}

## Step 6 — Plan your Kubernetes secrets

Several Kubernetes secrets come into play at install time. You create the first four below; the chart auto-generates the rest (with `helm.sh/resource-policy: keep`, so they survive upgrades and uninstalls).

| Secret                     | Who creates it             | Contents                                                                                                                                                                                                     |
| -------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `lunar-db`                 | You                        | DB `username` and `password`                                                                                                                                                                                 |
| `lunar-github-app`         | You (GitHub only)          | `private-key` — the PEM from Step 5                                                                                                                                                                          |
| `lunar-gitlab-token`       | You (GitLab only)          | `token` — the group access token from Step 5, mounted as a file                                                                                                                                              |
| `lunar-hub-licence`        | You                        | `hub-licence.jwt` — Hub licence token                                                                                                                                                                        |
| `regcred`                  | You (derived from licence) | GHCR image-pull credentials — generated by [`lunar licence pull-secret`](/install/lunar-hub/self-hosted/install-walkthrough#pulling-images-from-ghcr) from your licence JWT                                  |
| `<release>-auth-token`     | Chart (auto-generated)     | Shared bearer token for the CLI and CI tracers                                                                                                                                                               |
| `<release>-github-webhook` | Chart (auto-generated)     | Webhook signing secret for GitHub — the Hub registers it automatically when it creates per-repo webhooks. Override it per App with a `webhook_secret` in `HUB_GITHUB_APPS`                                   |
| `<release>-gitlab-webhook` | Chart (auto-generated)     | The same, for GitLab project hooks. Override it per group with a `webhook_secret` in `HUB_GITLAB_TOKENS`                                                                                                     |
| `<release>-grafana-admin`  | Chart (auto-generated)     | Grafana admin username and password — **only when the chart runs Grafana for you** ([Option A](/install/lunar-hub/self-hosted/install-walkthrough#grafana))                                                  |
| `<release>-grafana-db`     | Chart (auto-generated)     | Read-only `grafana_user` DB-role password (used by the dashboards' datasource) — whenever the provisioning Job runs (bundled **or** your own Grafana). BYO via `grafana.provisioning.dbPassword.secretName`. |

Pointing Lunar at **your own Grafana** ([Option B or C](/install/lunar-hub/self-hosted/install-walkthrough#grafana))? The chart doesn't generate `<release>-grafana-admin` — you create a secret holding your Grafana service-account token (or admin username/password). The `<release>-grafana-db` secret above is still chart-managed. See [Install Step 4 → Grafana](/install/lunar-hub/self-hosted/install-walkthrough#grafana).

{% hint style="info" %}
**Your licence also covers the CI tracer.** The credential the chart uses to pull Lunar's container images — derived from your licence with [`lunar licence pull-secret`](/install/lunar-hub/self-hosted/install-walkthrough#pulling-images-from-ghcr) for the `regcred` secret above — is also what the Hub uses to fetch the CI tracer binary for your runners. You don't need a separate credential for the CI tracer.

If your licence doesn't include CI tracer distribution, the Lunar CLI reports a clear error the first time a runner tries to download the tracer. If you plan to use the CI tracer, make sure your licence covers it — contact Earthly if you're unsure.
{% endhint %}

**GitOps alternative.** If your setup needs deterministic secret management, you can pre-create any chart-managed secret and point the chart at it (e.g. `hub.github.webhookSecret.secretName`). See the [chart README](https://github.com/earthly/charts/blob/main/README.md) for the full list of `*.secretName` values you can override.

The [install walkthrough](/install/lunar-hub/self-hosted/install-walkthrough#step-3-create-kubernetes-secrets) has the exact `kubectl create secret` commands for the three user-created secrets.

## Step 7 — Size for capacity

The chart sets **no** default resource requests or limits. The numbers below are reasonable starting points, but you should monitor and adjust them based on your specific needs.

| Component | CPU request | Memory request |
| --------- | ----------- | -------------- |
| Hub       | 500m        | 1 Gi           |
| Operator  | 100m        | 128 Mi         |

**Run pods** are short-lived batch pods spawned by the operator. Each pod contains N **user containers** (one per script in the batch) plus an init container and a sidecar. Per-user-container resources come from `operator.snippetContainerSpec*` — the operator's built-in defaults request 250m / 256 Mi for collectors and catalogers, 50m / 128 Mi for policies.

**Batch size** is per script type via `operator.batchMaxCount*` (defaults: 10 for collectors and catalogers, 20 for policies — policies pack denser because each container is lighter). Concurrent batch pods are capped by `operator.maxConcurrent` (default `10`), shared across script types.

Hub workers that feed run pods can be capped with `HUB_MAX_WORKERS_COLLECT`, `HUB_MAX_WORKERS_POLICY`, `HUB_MAX_WORKERS_CRON_COLLECT`, and `HUB_MAX_WORKERS_CATALOGER`; `0` means unlimited. Tune these when increasing `operator.maxConcurrent`. The Hub and operator have separate Postgres pool caps for operator work via `HUB_MAX_OPERATOR_POOL_SIZE` and `OPERATOR_MAX_POOL_SIZE`.

Policy result posting has its own per-Hub-replica cap, `HUB_MAX_WORKERS_POLICY_POST` (default `20`; `0` means unlimited). Set it through `hub.extraEnv` when you need more posting capacity. Posting previously shared `HUB_MAX_WORKERS_POLICY`; after upgrading, a custom value for that setting continues to control policy execution but does not raise the posting cap. Review both settings during upgrade, and monitor the `policy_post` queue backlog and oldest job age. Each repository permits one active posting invocation across replicas, while different repositories can post concurrently.

Repository reconciliation has a separate per-Hub-replica cap, `HUB_MAX_WORKERS_REPO_SYNC` (default `5`; `0` means unlimited). This needs to be set through `hub.extraEnv`:

```yaml
hub:
  extraEnv:
    - name: HUB_MAX_WORKERS_REPO_SYNC
      value: "1"
```

Lowering the cap spreads GitHub and GitLab API traffic over more time but does not reduce total request volume. Watch the `repo_sync` queue backlog and oldest available job age after lowering it; sustained growth means the cap is below the incoming work rate.

The run-pods namespace needs headroom for:

```
maxConcurrent × batchMaxCount(type) × per-user-container requests
```

At defaults that's \~25 GiB of concurrent-run memory per script type (10 pods × 10 collectors × 256 Mi, or 10 × 20 × 128 Mi for policies).

If you want sizing guidance once you're past initial install, please reach out.

## Next steps

When your prerequisites are in place:

* [Install walkthrough](/install/lunar-hub/self-hosted/install-walkthrough) — step-by-step from zero to a working Hub.
* [Chart README](https://github.com/earthly/charts/blob/main/README.md) — complete `values.yaml` reference.


# Install Walkthrough

This walkthrough takes you from zero to a working Lunar Hub. It assumes you've worked through the [prerequisites](/install/lunar-hub/self-hosted/prerequisites), and have:

* Kubernetes cluster with your namespaces created
* Postgres database
* two S3 buckets
* GitHub App
* hub licence key
* DNS name for the Hub

Assuming your prerequisites are in order, expect the install to take 15–30 minutes end to end. The bulk of that is waiting for external systems (e.g. first-boot migrations).

## Step 1 — Add the Helm repo

```bash
helm repo add earthly https://earthly.github.io/charts
helm repo update
```

Verify:

```bash
helm search repo earthly/lunar
```

You should see the chart listed with a version number.

## Step 2 — Verify your prerequisites

Before going further, sanity-check the external dependencies you provisioned in the [prerequisites](/install/lunar-hub/self-hosted/prerequisites). An issue here is cheaper to find now than after you've created secrets and written your `values.yaml`.

**DNS** ([prereqs Step 2](/install/lunar-hub/self-hosted/prerequisites#step-2-check-your-kubernetes-cluster)):

```bash
nslookup lunar.example.com
```

Should resolve to your ingress controller's external IP. If it doesn't yet, either wait or add an `A` / `CNAME` record now — GitHub webhook auto-registration needs a publicly reachable URL.

**Postgres reachability** ([step 3](/install/lunar-hub/self-hosted/prerequisites#step-3-provision-postgresql)):

```bash
kubectl run -it --rm --restart=Never -n lunar pg-check \
  --image=postgres:16 -- \
  psql "postgresql://<user>:<pass>@<host>:5432/lunar" \
  -c "SELECT 1;"
```

Should return `1`. If the connection fails or the role is rejected, fix that before installing — the Hub will crash-loop otherwise.

**S3 access** ([prereqs Step 4](/install/lunar-hub/self-hosted/prerequisites#step-4-provision-s3-compatible-object-storage)):

```bash
aws s3api head-bucket --bucket your-lunar-logs-bucket
aws s3api head-bucket --bucket your-lunar-resources-bucket
```

Both should succeed silently (exit code `0`). This requires the AWS CLI with credentials configured on your workstation — it confirms the buckets exist and that *some* credentials can reach them, but doesn't validate the in-cluster IRSA path. If your workstation isn't AWS-configured, skip this check; Hub logs surface S3 misconfiguration immediately on install.

## Step 3 — Create Kubernetes secrets

You'll need to create three Kubernetes secrets yourself:

* Database credentials
* Your Git platform credential: the GitHub App PEM, or the GitLab group access token, from [prereqs Step 5](/install/lunar-hub/self-hosted/prerequisites#step-5-connect-your-git-platform)
* A signed Hub licence JWT (from your Earthly contact)

Other secrets, like the Hub auth token, the webhook signing secrets, and the Grafana admin password, are auto-generated by the chart on first install and preserved across upgrades. You'll retrieve their values after install in [Step 6](#step-6-verify-the-install).

Create the three with `kubectl`, or provision them with your secret manager of choice, using the same names and keys as specified below.

**Database credentials:**

```bash
kubectl -n lunar create secret generic lunar-db \
  --from-literal=username='<db-user>' \
  --from-literal=password='<db-password>'
```

**Git platform credential** — create the one that matches your platform, or both if you run both:

{% tabs %}
{% tab title="GitHub" %}
Base64-encode the App's PEM and store it as the `private-key` field:

```bash
kubectl -n lunar create secret generic lunar-github-app \
  --from-literal=private-key="$(base64 < path/to/lunar-github-app-<id>.pem | tr -d '\n')"
```

{% hint style="info" %}
The PEM must be base64-encoded inside the secret value — the Hub decodes it after reading the env var. (Kubernetes then base64-encodes the whole secret again for etcd storage, so it's double-encoded at rest.)
{% endhint %}
{% endtab %}

{% tab title="GitLab" %}
The Hub reads the group access token from a mounted file, so it goes in as plain text with no base64 step:

```bash
kubectl -n lunar create secret generic lunar-gitlab-token \
  --from-literal=token='<your-group-access-token>'
```

Step 4 mounts this secret at the `token_path` you declare in `HUB_GITLAB_TOKENS`.
{% endtab %}
{% endtabs %}

**Hub licence JWT** — store the signed token in `hub-licence.jwt`:

```bash
kubectl -n lunar create secret generic lunar-hub-licence \
  --from-literal=hub-licence.jwt='<signed-licence-jwt>'
```

If your collectors need credentials at runtime (`github.*` collectors, Datadog, Jira, Linear, etc.), provision a fourth secret as well — see [Script runtime secrets](#script-runtime-secrets) at the bottom of this page.

## Step 4 — Write `values.yaml`

Create a `values.yaml` with the minimum configuration the chart needs. The example below is a working baseline — copy it, fill in the placeholders, and adjust for your environment. Its `hub.github` block assumes GitHub; [Git platform credentials](#git-platform-credentials) below covers what to change on GitLab.

```yaml
# Cluster assumptions baked into this sample:
#   - NGINX ingress controller    → hub.ingress.className + grpcAnnotations
#   - cert-manager for TLS        → ingress annotations (drop if BYO secret)
#   - EKS IRSA for S3 credentials → serviceAccount.annotations (see "AWS credentials" below)

# IRSA on EKS: annotate the service account with your IAM role ARN.
# See "AWS credentials" below for non-EKS options.
serviceAccount:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/lunar-hub-s3

hub:
  publicBaseURL: "https://lunar.example.com"
  licence:
    secretName: "lunar-hub-licence"
    secretKey: "hub-licence.jwt"

  # Run the Hub as multiple replicas. The Hub is stateless — all state lives
  # in Postgres and S3 — so replicas coordinate through Postgres (leader
  # election, advisory locks) with no peer-to-peer wiring. See "Replicas and
  # the Postgres connection budget" below before raising this.
  replicaCount: 3
  # Keep a quorum during node drains and rollouts. Enable this whenever
  # replicaCount > 1 (a PDB on a single replica blocks voluntary node drains).
  podDisruptionBudget:
    enabled: true
    maxUnavailable: 1

  # Pin all four image tags (hub, operator, initImage, sidecarImage) to the
  # same released hub version for a reproducible install. The latest is
  # published to ghcr.io/earthly/lunar-hub — see the repo's `lunar-hub-v*`
  # release tags. This example is bumped automatically on each Hub release.
  image:
    tag: "3.21.2"

  db:
    host: "your-db-host.example.com"
    name: "lunar"

  s3:
    logsBucket: "your-lunar-logs-bucket"
    resourcesBucket: "your-lunar-resources-bucket"

  github:
    app:
      # Don't forget the quotes, otherwise they might get rendered as scientific notation!
      id: "123456"
      installId: "78901234"
      # The GitHub org (or user) the App is installed on. Required since chart 2.2.0.
      owner: "<your-github-org>"

  # AWS region for S3. Credentials themselves come from the AWS SDK
  # credential chain (IRSA above, or see "AWS credentials" below).
  extraEnv:
    - name: AWS_REGION
      value: us-east-1

  # The chart sets no resource defaults. Numbers below are the starting
  # recommendations from prereqs Step 7 — tune as you observe real load.
  resources:
    requests:
      cpu: 500m
      memory: 1Gi

  ingress:
    enabled: true
    host: lunar.example.com
    className: nginx
    tls:
      - secretName: lunar-tls
        hosts: [lunar.example.com]
    annotations:
      # cert-manager provisions the `lunar-tls` secret automatically.
      # Remove this annotation if you supply the TLS secret yourself
      # (corporate CA, external LB, ACM, etc.).
      cert-manager.io/cluster-issuer: letsencrypt
    # gRPC backend-protocol applies only to the gRPC Ingress. Both
    # Ingresses are always rendered when ingress.enabled is true.
    grpcAnnotations:
      nginx.ingress.kubernetes.io/backend-protocol: "GRPC"

operator:
  # Run pods are created here. The namespace must already exist; the chart
  # will not create it. See prereqs Step 1 for the trust-boundary rationale.
  snippetNamespace: "lunar-scripts"

  image:
    tag: "3.21.2"
  initImage:
    tag: "3.21.2"
  sidecarImage:
    tag: "3.21.2"

  resources:
    requests:
      cpu: 100m
      memory: 128Mi

# Grafana — the primary UI for Lunar. Enabled by default: the chart runs a
# stock Grafana and installs Lunar's dashboards into it. This block just wires
# up its ingress. If you'd rather use a Grafana you already run (Cloud,
# Enterprise, or self-hosted OSS), drop this block and see the "Grafana"
# section below for how to point Lunar at it instead.
grafana:
  ingress:
    enabled: true
    hosts: [grafana.lunar.example.com]
    tls:
      - secretName: lunar-grafana-tls
        hosts: [grafana.lunar.example.com]
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt
```

Everything else has a sensible default. See the [chart README](https://github.com/earthly/charts/blob/main/README.md#values-reference) for the full values reference.

### Git platform credentials

Serving both platforms from one Hub is supported: configure both, and each repository is routed by its host.

{% tabs %}
{% tab title="GitHub" %}
The `hub.github.app` block in the sample above is the whole configuration. Its `id`, `installId`, and `owner` come from [prereqs Step 5](/install/lunar-hub/self-hosted/prerequisites#step-5-connect-your-git-platform); the private key comes from the `lunar-github-app` secret created in Step 3. All four are required together; a partial App configuration is rejected at startup.

On **GitHub Enterprise Server**, also point the Hub at your instance:

```yaml
hub:
  github:
    baseUrl: https://github.example.com
```

Fronting several GitHub orgs from one Hub takes a different shape. See [GitHub → multiple organizations](/install/git-platforms/github#multiple-organizations).
{% endtab %}

{% tab title="GitLab" %}
The chart has dedicated values for the GitHub App (`hub.github.app.*`) but not yet for GitLab, so GitLab credentials go in through the generic passthroughs: the environment variable via `hub.extraEnv`, and the token file via a mounted secret.

```yaml
hub:
  extraEnv:
    # ... alongside AWS_REGION from the sample above
    - name: HUB_GITLAB_TOKENS
      value: '[{"group":"acme","host":"gitlab.com","token_path":"/secrets/gitlab/token"}]'

  volumeMounts:
    - name: gitlab-token
      mountPath: /secrets/gitlab
      readOnly: true
  volumes:
    - name: gitlab-token
      secret:
        secretName: lunar-gitlab-token
```

One entry per token; `token_path` must match the `mountPath` above. `group` binds the token to that group and its subtree, which is what a GitLab.com group service account needs; leave it out for an instance service account on self-managed, and the token serves every group the account is a member of. The webhook signing secret is not in there: the chart generates it, the same as it does for GitHub. [GitLab → Hub configuration](/install/git-platforms/gitlab#hub-configuration-self-hosted) has the full field reference and the multi-group form.

{% hint style="warning" %}
**Keep the `hub.github` block even on a GitLab-only install.** The Hub itself needs only one Git platform configured, but the chart does not yet know that: its template guard requires the GitHub App values and aborts the render with `hub.github.app.id is required (numeric, non-zero)` when they are absent. Until the chart gains dedicated GitLab values, a chart install has to carry a GitHub App configuration alongside `HUB_GITLAB_TOKENS`.
{% endhint %}
{% endtab %}
{% endtabs %}

### Replicas and the Postgres connection budget

The Hub is stateless, so `replicaCount` scales it horizontally with no extra wiring — but each replica opens its own pool of Postgres connections, so total connections scale roughly linearly with the replica count:

```
total ≈ replicaCount × (hub.db.maxOpenConns + hub.db.maxPoolConns + hub.db.operatorPoolSize)
```

The chart defaults (`40 + 40 + 5 ≈ 85` per replica) match the Hub's built-in values, so a single replica is unchanged. Before running several replicas, confirm the total stays within your Postgres `max_connections` — leave headroom for the migrate Job, `psql`, and superuser-reserved slots. For larger replica counts, **lower `hub.db.maxOpenConns` / `hub.db.maxPoolConns`** so the total fits, and/or front Postgres with a connection pooler. Keep `maxPoolConns` at or above your peak concurrent queue workers (the sum of `hub.maxWorkers.*`) — the pool is shared by every queue, so a pool below that serializes store queries under load.

The chart soft-spreads replicas across nodes by default (`topologySpreadConstraints`, `maxSkew 1` across `kubernetes.io/hostname`, `ScheduleAnyway`) so a single node loss doesn't take out the whole Hub. Override `hub.topologySpreadConstraints` to impose your own (e.g. a hard zone spread) — it replaces the default wholesale.

### A note on ingress

The Hub serves two protocols on separate ports — gRPC on `8000` (API) and plain HTTP on `8001` (webhook receiver + pre-signed URL redirector for run logs). Most ingress controllers apply `backend-protocol` per-Ingress rather than per-path, so the chart renders **two** Ingress resources sharing the same hostname and TLS config whenever `hub.ingress.enabled: true`. Per-protocol annotations layer over the shared `annotations` map: `grpcAnnotations` apply only to the gRPC Ingress, `httpAnnotations` only to the HTTP one. Both Ingresses are always created together — there's no per-protocol toggle.

If you'd rather front the Hub yourself (a controller with per-path backend protocols, a service mesh, or a LoadBalancer Service), set `hub.ingress.enabled: false` and route to the `hub-grpc` (port `8000`) and `hub-http` (port `8001`) service ports directly.

### Grafana

Lunar ships dashboards for policy results, component health, and collection activity, and installs them — with their datasources and panel plugins — into a Grafana over its HTTP API. There are three ways to provide that Grafana; pick the one that matches what you already run. The `grafana:` block in the values above shows the first (default) option.

In every case the chart auto-generates the read-only `grafana_user` datasource password (`<release>-grafana-db`) and wires it end-to-end for you. If you'd rather provide your own secret, set `grafana.provisioning.dbPassword.secretName` (recommended for GitOps).

#### Option A — bundled Grafana (default)

The default (`grafana.mode: chart`): the chart deploys a stock Grafana next to the Hub, runs a post-install/upgrade Job that installs the dashboards into it, and auto-generates the admin login (which the Hub uses to reach the pod).

Wire up the ingress in the `grafana:` block above so your team can reach it in a browser, and retrieve the admin login in [Step 6](#step-6-verify-the-install). Nothing else is required — `grafana.mode` defaults to `chart`.

#### Option B — Grafana Cloud or Grafana Enterprise (service-account token)

Point the Hub at your existing Grafana with a [service-account token](https://grafana.com/docs/grafana/latest/administration/service-accounts/). Create a service account with the **Admin** role, generate a token, then disable the bundled Grafana and wire the token in:

```yaml
grafana:
  mode: external                  # bring your own Grafana (no bundled pod)
  url: "https://your-org.grafana.net"
  auth:
    secretName: lunar-grafana     # holds the service-account token (created below)
    tokenKey: token
```

That's the whole configuration. On `helm install` / `helm upgrade` the chart's `post-install` / `post-upgrade` hook Job runs the provisioning tool against your Grafana — `deploy.sh` resolves the URL, credentials, and read-only datasource connection from the Hub over gRPC, then installs the plugins, datasources, and dashboards over Grafana's HTTP API. The Job is idempotent and non-blocking (a failed provision is logged but never rolls back the Hub upgrade), and it re-runs on every subsequent upgrade so the dashboards stay in sync with the Hub's schema. No manual step.

#### Option C — self-hosted OSS Grafana (admin username + password)

Same as Option B, but OSS Grafana can't install plugins with a service-account token — the dashboards' panel plugins need a **server admin**. Give the Hub an admin username and password instead of a token:

```yaml
grafana:
  mode: external
  url: "https://grafana.example.com"
  auth:
    secretName: lunar-grafana     # holds username + password (created below)
    # userKey / passwordKey default to "username" / "password"
```

Deploy the same way as Option B: `helm install` / `helm upgrade` runs the chart's hook Job automatically.

The examples reference a `lunar-grafana` secret you create — the key depends on your option: `token` for Option B, or `username` + `password` for Option C:

```bash
# Option B (Cloud / Enterprise)
kubectl -n lunar create secret generic lunar-grafana \
  --from-literal=token='<grafana-service-account-token>'

# Option C (OSS) — use these keys instead of `token`
kubectl -n lunar create secret generic lunar-grafana \
  --from-literal=username='admin' \
  --from-literal=password='<grafana-admin-password>'
```

{% hint style="info" %}
`grafana.url` + `grafana.auth` are all the chart needs for an external Grafana — it wires them into the Hub for you (as `HUB_GRAFANA_URL_BASE` and `HUB_GRAFANA_TOKEN`, or `HUB_GRAFANA_USER` / `HUB_GRAFANA_PASSWORD`), so there's no `hub.extraEnv` plumbing. The chart's provisioning Job runs whenever `grafana.mode != off` — the same Job that serves the bundled pod — installing the dashboards on every install/upgrade.
{% endhint %}

{% hint style="warning" %}
Options B and C need three network paths:

* **your Grafana → the Hub's Postgres** (as the read-only `grafana_user` role) — the Postgres datasource
* **your Grafana → the Hub's HTTP API** — the Infinity datasource
* **the in-cluster Job → your Grafana** (HTTP) — the deploy Job

Same-network self-hosted Grafana has all three. Grafana Cloud needs a route to both Hub endpoints (public endpoint, PrivateLink, or Private Data Source Connect) so the in-cluster Job can reach it.
{% endhint %}

### AWS credentials

The chart does not manage AWS credentials. The example above uses **IRSA on EKS** (annotation on the service account), which is the recommended pattern. The conceptual breakdown of patterns is in [prereqs Step 4 → Region and credentials](/install/lunar-hub/self-hosted/prerequisites#region-and-credentials).

### Pulling images from GHCR

Lunar Hub images are published to a private GitHub Container Registry (`ghcr.io/earthly/*`). To pull them your cluster needs an image pull secret. You can derive the pull secret with the [Lunar CLI](/install/cli):

```bash
lunar licence pull-secret \
  --licence-file=path/to/hub-licence.jwt \
  --namespace=lunar \
  --name=regcred | kubectl apply -f -
```

If your snippet pods run in a separate namespace (`operator.snippetNamespace`), run it again for that namespace:

```bash
lunar licence pull-secret \
  --licence-file=path/to/hub-licence.jwt \
  --namespace=lunar-scripts \
  --name=regcred | kubectl apply -f -
```

Reference the secret in your `values.yaml`:

```yaml
imagePullSecrets:
  - name: regcred

hub:
  image:
    repository: ghcr.io/earthly/lunar-hub

operator:
  image:
    repository: ghcr.io/earthly/lunar-snippet-operator
  initImage:
    repository: ghcr.io/earthly/lunar-snippet-init
  sidecarImage:
    repository: ghcr.io/earthly/lunar-snippet-sidecar
```

Lunar also ships the Grafana dashboards in a private deploy image, `ghcr.io/earthly/lunar-dashboards`, which the chart's dashboards-deploy Job pulls under the same `imagePullSecrets` — it uses that repository by default, so no extra image configuration is needed.

## Step 5 — Install

```bash
helm install lunar earthly/lunar \
  --namespace lunar \
  -f values.yaml
```

The chart fails fast with a clear error if any of the [required fields](https://github.com/earthly/charts/blob/main/README.md#required-values) are missing.

Watch the pods come up:

```bash
kubectl -n lunar get pods -w
```

Expected rollout, in order:

1. `lunar-hub-migrate-<hash>` runs first — a pre-install Helm hook Job that applies DB migrations and gates the rest of the rollout. It runs to `Completed` before any Hub pod is created.
2. `lunar-hub-<hash>` pods start (one per `replicaCount`). Each asserts the schema is current at boot and refuses to start if it's behind — so serving pods never run against an un-migrated schema. They flip to `Running 1/1` once the readiness probe (`/ready`) passes.
3. `lunar-operator-<hash>` depends on the applied migrations. Once the Hub is up, it flips to `Running 1/1` shortly after.

Migrations can take a minute or two on a warm DB. If the migrate Job fails, or a Hub pod restarts repeatedly, check the logs:

```bash
# Migrate Job (if it failed the hook, the whole install/upgrade is rolled back):
kubectl -n lunar logs -l app.kubernetes.io/component=hub-migrate --tail=200
# Hub pods:
kubectl -n lunar logs -l app.kubernetes.io/component=hub --tail=200
```

Common first-install failures (visible while the Hub is starting up):

| Symptom in logs                             | Fix                                                                                                                                                                                                                                                                                                                                                |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pq: permission denied for schema public`   | The DB role lacks `CREATE` on the database. Grant it, or use a database-owner role. See [prerequisites step 3](/install/lunar-hub/self-hosted/prerequisites#step-3-provision-postgresql).                                                                                                                                                          |
| `dial tcp ...: i/o timeout`                 | The Hub pod can't reach Postgres on the network — security group, network policy, or firewall block between the pod and the DB. Check VPC routing and any `NetworkPolicy` in the `lunar` namespace.                                                                                                                                                |
| `dial tcp ...: connect: connection refused` | Postgres is reachable but not listening on the configured port (`hub.db.port`, default `5432`), or the process is down.                                                                                                                                                                                                                            |
| `lookup ...: no such host`                  | DNS issue — the DB hostname doesn't resolve from inside the cluster.                                                                                                                                                                                                                                                                               |
| `failed to parse private key`               | The `lunar-github-app` secret doesn't contain a valid PEM, or the PEM wasn't base64-encoded before going into the secret value (see the base64 note in [Step 3](#step-3-create-kubernetes-secrets)). Recreate from the PEM you saved during [prerequisites step 5](/install/lunar-hub/self-hosted/prerequisites#step-5-connect-your-git-platform). |
| `no forge configured`                       | Neither GitHub App credentials nor `HUB_GITLAB_TOKENS` are set. At least one Git platform is required. See [prerequisites step 5](/install/lunar-hub/self-hosted/prerequisites#step-5-connect-your-git-platform).                                                                                                                                  |
| `read HUB_GITLAB_TOKENS ... token_path`     | The GitLab token file isn't where the config says it is. Confirm `token_path` matches the `mountPath` of the volume you mounted the `lunar-gitlab-token` secret at.                                                                                                                                                                                |

The Hub validates Postgres at startup, but **defers GitHub and S3 to first use** — those misconfigurations won't appear in install logs. They surface once something exercises them (e.g. `lunar hub pull github://...` or a script run):

| Symptom (after triggering an operation)     | Likely cause                                                                                                                                                                                               |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401 Unauthorized` from GitHub API          | Wrong `hub.github.app.id`, or the PEM in the `lunar-github-app` secret doesn't match the App GitHub knows about.                                                                                           |
| `404 Not Found` on `installations/<id>/...` | Wrong `hub.github.app.installId`, or the App is no longer installed on the org.                                                                                                                            |
| `AccessDenied` from S3                      | IAM policy too narrow — confirm `s3:GetObject` and `s3:PutObject` on both buckets. See [prerequisites step 4](/install/lunar-hub/self-hosted/prerequisites#step-4-provision-s3-compatible-object-storage). |
| `NoSuchBucket` from S3                      | Bucket name mismatch between `hub.s3.logsBucket` / `hub.s3.resourcesBucket` and what actually exists.                                                                                                      |
| `PermanentRedirect` from S3                 | `AWS_REGION` doesn't match the bucket's region.                                                                                                                                                            |

## Step 6 — Verify the install

Once the Hub and Operator pods are `Running 1/1`, confirm the Hub is healthy end to end.

**Retrieve the Hub auth token** — the chart generated this on first install. You'll need it for the CLI step below.

```bash
kubectl -n lunar get secret lunar-auth-token \
  -o jsonpath='{.data.token}' | base64 -d
```

**Grafana access** — this depends on which [Grafana option](#grafana) you chose.

If you use the **bundled Grafana** (Option A), retrieve its auto-generated admin credentials:

```bash
kubectl -n lunar get secret lunar-grafana-admin \
  -o jsonpath='{.data.username}' | base64 -d
  
kubectl -n lunar get secret lunar-grafana-admin \
  -o jsonpath='{.data.password}' | base64 -d
```

The commands above assume the release name `lunar` (from `helm install lunar ...`). For other release names, see [Required secrets](https://github.com/earthly/charts/blob/main/README.md#required-secrets) in the chart README for the resolved name.

Open `https://grafana.lunar.example.com` and log in with the credentials above. The home dashboard surfaces policy results, component health, and collection activity. If the page doesn't load, confirm the `grafana.lunar.example.com` `A` / `CNAME` record points at your ingress controller and the cert is valid.

If you pointed Lunar at **your own Grafana** (Option B or C), log in there with your existing credentials — the Lunar dashboards appear in a "Lunar" folder.

**HTTP ingress reachability** — the webhook endpoint rejects unsigned requests but returns a predictable status, so it's a decent routing smoke test:

{% tabs %}
{% tab title="GitHub" %}

```bash
curl -sS -o /dev/null -w "%{http_code}\n" https://lunar.example.com/webhooks/github
```

{% endtab %}

{% tab title="GitLab" %}

```bash
curl -sS -o /dev/null -w "%{http_code}\n" https://lunar.example.com/webhooks/gitlab
```

{% endtab %}
{% endtabs %}

Expected: `405`. The endpoints accept only `POST`, and the method check runs before any signature validation, so a plain `GET` like this is rejected with `405 Method Not Allowed`. That still proves the route reaches the Hub. `404` or `502` probably means the HTTP Ingress isn't reaching the Hub, so confirm `hub.ingress.enabled: true` in your values, and that DNS still resolves to your ingress controller.

**CLI connectivity smoke test** — install the [Lunar CLI](/install/cli), then save this minimal [`lunar-config.yml`](/configuration/lunar-config) locally and run:

```yaml
version: 0
hub:
  host: lunar.example.com
  grpcPort: 443
  httpPort: 443
collectors: []
policies: []
```

```bash
export LUNAR_HUB_TOKEN=<token-from-step-above>
lunar secret list
```

This authenticates over gRPC and returns the configured script runtime secrets (an empty list on a fresh install). Success means the CLI can reach the Hub through ingress, TLS terminates correctly, and the auth token is valid. Adjust the ports if you're using NodePort or non-default ingress.

**End-to-end with a config repo** — if you have a [`lunar-config.yml`](/configuration/lunar-config) repository ready, pull it in. This exercises the full path: CLI → Hub → your Git platform → repo fetch → Hub stores config. As a side effect, the Hub registers a webhook on every repository referenced in the config.

{% tabs %}
{% tab title="GitHub" %}

```bash
lunar hub pull github://your-org/your-config-repo@main
```

Verify the webhook on any repository in the config: repo → **Settings → Webhooks**, pointing at `https://lunar.example.com/webhooks/github`.
{% endtab %}

{% tab title="GitLab" %}

```bash
lunar hub pull gitlab://gitlab.com/your-group/your-config-repo@main
```

The host is required in the URL, even for gitlab.com.

Verify the webhook on any project in the config: project → **Settings → Webhooks**, pointing at `https://lunar.example.com/webhooks/gitlab`.
{% endtab %}
{% endtabs %}

If you don't have a config repo yet, that's the natural next step — see [the lunar-config docs](/configuration/lunar-config) for setup.

## Script runtime secrets

Collectors or catalogers that hit external APIs (`github.*` collectors, Datadog, Jira, Linear, etc.) may need credentials at runtime. Each plugin's README in [`earthly/lunar-lib`](https://github.com/earthly/lunar-lib) lists the `LUNAR_SECRET_*` variables it expects — drop the prefix when writing the secret. The Hub re-adds `LUNAR_SECRET_` when it injects each key as an env var in the script pod, so `GH_TOKEN` in the secret surfaces as `$LUNAR_SECRET_GH_TOKEN` in the script.

Provision the secret with one `--from-literal` per variable:

```bash
kubectl -n lunar create secret generic lunar-script-secrets \
  --from-literal=GH_TOKEN='<your-pat>' \
  --from-literal=LINEAR_API_KEY='<your-linear-key>'
  # ... one --from-literal per LUNAR_SECRET_* variable your plugins expect
```

Wire it into `values.yaml` (`hub.secrets.*.perKey` requires chart `2.2.0+`). The same secret can back both scopes; omit the one you don't use:

```yaml
hub:
  secrets:
    collector:
      secretName: lunar-script-secrets
      perKey: true
    cataloger:
      secretName: lunar-script-secrets
      perKey: true
```

See [day-2 → Other secrets](/install/lunar-hub/self-hosted/day-2-operations#other-secrets) for rotation.

## Next steps

* [Install the Lunar CLI](/install/cli) on your workstation.
* [Install the Lunar CI Tracer](/install/ci-tracer/github-actions-self-hosted) on your CI runners.
* [Day-2 operations](/install/lunar-hub/self-hosted/day-2-operations) — upgrades, secret rotation, observability, uninstall.


# Sync Config

Keep Lunar Hub's copy of your config repo current on every push — with the sync-config GitHub Action, or a GitLab CI job.

Lunar Hub keeps its own copy of your configuration (the manifest plus your snippets), and it needs refreshing whenever your config repository changes. Both options below wrap the same command, [`lunar hub pull`](/docs/lunar-cli#lunar-hub-pull), so the CLI reference is the source of truth for the flags. Run the one that matches where your config repository lives, on every push to its default branch.

## GitHub Actions

The [`earthly/lunar-actions/sync-config`](https://github.com/earthly/lunar-actions/tree/main/sync-config) action pushes the latest config (manifest + snippets) from your config repo into Lunar Hub.

{% hint style="info" %}
**First time setting up a config repo?** Fork [`earthly/lunar-config-template`](https://github.com/earthly/lunar-config-template) — it ships with this workflow pre-wired so you only need to set the `LUNAR_HUB_TOKEN` secret and push.
{% endhint %}

The action's inputs map 1:1 to the equivalent CLI flags — see the [CLI reference](/docs/lunar-cli#lunar-hub-pull) for the full semantics of each.

### Example

```yaml
name: Sync Lunar Config

on:
  push:
    branches: [main]

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: earthly/lunar-actions/sync-config@main
        with:
          manifest-url: github://my-org/my-config-repo@main
          hub-token: ${{ secrets.LUNAR_HUB_TOKEN }}
          hub-host: hub.example.com
          lunar-version: v2.5.0
          rerun-code-collectors: "true"
```

### Inputs

| Input                   | Required | Default | CLI equivalent                                                                                                  |
| ----------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------- |
| `manifest-url`          | yes      | —       | `<repo>` positional argument (`github://<org>/<repo>[/<config-path>]@<branch>`)                                 |
| `hub-token`             | yes      | —       | `LUNAR_HUB_TOKEN` env var                                                                                       |
| `hub-host`              | yes      | —       | `LUNAR_HUB_HOST` env var                                                                                        |
| `lunar-version`         | yes      | —       | Selects the [`lunar-dist`](https://github.com/earthly/lunar-dist/tags) release to download                      |
| `hub-grpc-port`         | no       | `443`   | `LUNAR_HUB_GRPC_PORT` env var                                                                                   |
| `hub-http-port`         | no       | `443`   | `LUNAR_HUB_HTTP_PORT` env var                                                                                   |
| `rerun-code-collectors` | no       | `false` | `--rerun-code-collectors` / `-l`                                                                                |
| `include-pr-commits`    | no       | `false` | `--include-pr-commits`                                                                                          |
| `pr-max-age-days`       | no       | `5`     | `--pr-max-age-days`                                                                                             |
| `rerun-catalogers`      | no       | `false` | `--rerun-catalogers` / `-t` (requires lunar `v1.1.2+` — see [`lunar hub pull`](/docs/lunar-cli#lunar-hub-pull)) |
| `log-level`             | no       | `debug` | `LUNAR_LOG_LEVEL` env var                                                                                       |

{% hint style="info" %}
By default, pulling the config does **not** rerun catalogers or code collectors. Set `rerun-catalogers: "true"` or `rerun-code-collectors: "true"` to opt in. Per-component catalogers (`component-repo`, `component-cron`) are unaffected by these flags and continue to run on their own hooks.
{% endhint %}

### Versioning

Pin to `@main` for the latest, or to a release tag for stability. The `lunar-version` input selects the CLI version that gets downloaded; check the [`lunar-dist` releases](https://github.com/earthly/lunar-dist/tags) for the latest stable tag.

## GitLab CI

There is no published GitLab CI component or template — you write a short job that runs the same `lunar hub pull` on every push to the config repository's default branch:

{% code title=".gitlab-ci.yml" %}

```yaml
sync-lunar-config:
  image: ubuntu:24.04
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  variables:
    LUNAR_HUB_HOST: lunar.example.com
    LUNAR_HUB_GRPC_PORT: "443"
    LUNAR_HUB_HTTP_PORT: "443"
  before_script:
    - apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
    - curl -fsSL -o /usr/local/bin/lunar
      "https://github.com/earthly/lunar-dist/releases/latest/download/lunar-linux-amd64"
    - chmod +x /usr/local/bin/lunar
  script:
    - lunar hub pull "gitlab://gitlab.com/$CI_PROJECT_PATH@$CI_COMMIT_BRANCH"
```

{% endcode %}

Set `LUNAR_HUB_TOKEN` as a **masked CI/CD variable** on the project or group (**Settings → CI/CD → Variables**), not in the file.

{% hint style="info" %}
The manifest URL must include the host, even for gitlab.com: `gitlab://gitlab.com/group/project@branch`. GitLab namespaces can contain dots, so Lunar cannot tell a host-less first segment apart from a group name.

For nested subgroups, `$CI_PROJECT_PATH` already expands to the full path (`group/subgroup/project`), so the same line works unchanged.
{% endhint %}

### Useful flags

`lunar hub pull` accepts several options that change what happens after the config lands. The full list is in the [CLI reference](/docs/lunar-cli#lunar-hub-pull); the ones most often wanted in this job:

| Flag                      | Effect                                                          |
| ------------------------- | --------------------------------------------------------------- |
| `--rerun-code-collectors` | Re-run affected code collectors after the pull. Off by default. |
| `--rerun-catalogers`      | Re-run global catalogers after the pull. Off by default.        |
| `--dry-run`               | Validate without applying, and without contacting the Hub.      |

### Validating config on merge requests

`--dry-run` needs no Hub connection, which makes it a good merge-request check. It does need to reach GitLab to resolve any `uses:` plugins:

{% code title=".gitlab-ci.yml" %}

```yaml
validate-lunar-config:
  image: ubuntu:24.04
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    LUNAR_GITLAB_TOKEN: $LUNAR_GITLAB_TOKEN
  script:
    - lunar hub pull --dry-run "gitlab://gitlab.com/$CI_PROJECT_PATH@$CI_COMMIT_SHA"
```

{% endcode %}

The token needs read access to the config repository and to any repositories its plugins come from. See [Validating your config](/configuration/lunar-config/validation).


# Day-2 Operations

This page covers what you need to run Lunar Hub after the initial install: upgrades, secret rotation, observability, and uninstall. For the per-version upgrade notes (what values changed, what to migrate), see the [chart README's Upgrading section](https://github.com/earthly/charts/blob/main/README.md#upgrading).

## Upgrading

```bash
helm repo update
helm upgrade lunar earthly/lunar \
  --namespace lunar \
  -f values.yaml
```

Migrations run once per release as a **pre-rollout Job** (`lunar-hub-migrate`), installed via a Helm pre-install/pre-upgrade hook that completes — and gates the rollout — before any new Hub pod is created. Serving pods then **assert the schema is current at boot and refuse to start if it's behind**, which also keeps scaled-up and restarted pods safe (they don't re-run the Job). Migrations are forward-only and an interrupted one is safe to retry.

**Before every upgrade:**

1. **Back up Postgres.** Migrations are forward-only. If you need to roll back, roll the database back.
2. **Preview the chart changes.** The [`helm-diff`](https://github.com/databus23/helm-diff) plugin is worth installing — `helm diff upgrade lunar earthly/lunar -f values.yaml` shows exactly what Kubernetes resources will change.
3. **Read the version-specific notes** in the [chart README](https://github.com/earthly/charts/blob/main/README.md#upgrading). Minor version bumps occasionally require values changes.
4. **Pin your new image tags** in `values.yaml`. Don't rely on the chart's default tags.

{% hint style="warning" %}
Migrations are forward-only — back up Postgres first (step 1). Author schema changes **expand/contract** (add first, drop in a later release) so the common upgrade stays zero-downtime: the migrate Job runs while the old-version pods keep serving, then Helm rolls the Deployment. Reserve a maintenance window only for an unavoidable breaking change, and confirm the budget with stakeholders — a heavy migration can take tens of minutes, during which the old version keeps serving.
{% endhint %}

With multiple replicas and a PodDisruptionBudget, Helm's rolling update replaces Hub pods a few at a time while the rest keep serving, so a routine upgrade has no API-unavailability window.

## Rotating secrets

Lunar's secrets split into two categories — **user-managed** (you create and rotate them) and **chart-managed** (auto-generated on first install, preserved across upgrades via `helm.sh/resource-policy: keep`). See [Required secrets](https://github.com/earthly/charts/blob/main/README.md#required-secrets) in the chart README for the full list and naming rules.

### Hub auth token (`<release>-auth-token`)

Used by the CLI and CI tracers to authenticate to the Hub. The Hub accepts a single token today — rotation requires a coordinated cutover. To force a regeneration, delete the secret and re-run `helm upgrade` (the chart will generate a fresh token):

```bash
kubectl -n lunar delete secret lunar-auth-token
helm upgrade lunar earthly/lunar -n lunar -f values.yaml
kubectl -n lunar rollout restart deployment/lunar-hub
kubectl -n lunar rollout restart deployment/lunar-operator
```

Both the Hub and the Operator read this token from the same secret (the Operator uses it as `OPERATOR_HUB_TOKEN` to call the Hub), so restart both. Retrieve the new token with `kubectl -n lunar get secret lunar-auth-token -o jsonpath='{.data.token}' | base64 -d`, then update every CI tracer's `LUNAR_HUB_TOKEN` and every developer's CLI config. Active builds authenticated with the old token will fail mid-run and need to be retried. Dual-token support is on the roadmap.

To pin to an externally-managed token instead, set `hub.auth.secretName` in values to a secret you control — the chart will consume it instead of generating its own.

### Git platform credentials

Both are read once at boot, so a rolling restart is always required after updating the secret.

{% tabs %}
{% tab title="GitHub" %}
**GitHub App private key (`lunar-github-app`).** GitHub allows multiple active keys per App, so this rotates with zero downtime:

1. **Generate a new key** in GitHub → **Apps → your App → Private keys → Generate a private key**. Download the new PEM.
2. **Update the Kubernetes secret** in place (the Hub expects a base64-encoded PEM inside the secret value):

   ```bash
   kubectl -n lunar create secret generic lunar-github-app \
     --from-literal=private-key="$(base64 < path/to/new-key.pem | tr -d '\n')" \
     --dry-run=client -o yaml | kubectl apply -f -
   ```
3. **Roll the Hub** — `kubectl -n lunar rollout restart deployment/lunar-hub`.
4. **Verify** webhooks still deliver (GitHub → any repo → **Settings → Webhooks → Recent Deliveries**).
5. **Delete the old key** in GitHub.
   {% endtab %}

{% tab title="GitLab" %}
**Group access token (`lunar-gitlab-token`).** Revoking the old token takes effect immediately, so revoke it last:

1. **Create the replacement token** in GitLab (group → **Settings → Access tokens**), with the same scope and role as the original.
2. **Update the secret in place:**

   ```bash
   kubectl -n lunar create secret generic lunar-gitlab-token \
     --from-literal=token='<new-token>' \
     --dry-run=client -o yaml | kubectl apply -f -
   ```
3. **Roll the Hub** — `kubectl -n lunar rollout restart deployment/lunar-hub`.
4. **Revoke the old token** in GitLab.

{% hint style="warning" %}
GitLab access tokens expire on a fixed date and nothing renews them. When one lapses, Lunar silently stops posting results and reacting to webhooks for that group. Put the expiry date in a calendar and rotate ahead of it.
{% endhint %}
{% endtab %}
{% endtabs %}

### Webhook secrets

The chart generates one of these per platform.

{% hint style="warning" %}
**Rotating a webhook secret costs you deliveries, on both platforms.** A hook carries the secret it was created with, so every repository that already has one starts failing signature validation the moment Lunar Hub restarts on the new value.

On **GitHub** the fleet repairs itself from there, a repository at a time as each reports the problem, at up to 3,600 an hour — often a working day or less, though GitHub's own rate limiting can stretch it. On **GitLab** there is no self-heal at all, and the window is not free: failing deliveries get the hook [auto-disabled](#webhook-health), permanently after 40 consecutive failures.
{% endhint %}

Start the same way on both platforms:

1. **Replace the secret and restart.** Until Lunar Hub restarts it keeps validating against the old value, and it also stamps the old value onto any hook it creates.
2. **Repair the hooks.** GitHub does this on its own; on GitLab you delete each hook so Lunar recreates it. See the tab for your platform.
3. **Confirm deliveries are succeeding again** before considering the rotation done — see [Webhook health](#webhook-health).

{% tabs %}
{% tab title="GitHub" %}

```bash
kubectl -n lunar delete secret lunar-github-webhook
helm upgrade lunar earthly/lunar -n lunar -f values.yaml
kubectl -n lunar rollout restart deployment/lunar-hub
```

Nothing else to do. A rejected delivery is how Lunar Hub finds out a hook is stale: it recognizes the signature failure, checks that the hook is one it installed, and re-stamps the current secret onto it. That repository's deliveries succeed from then on.

Repairs run one at a time, a second apart — the gap GitHub asks for between modifying requests. At best that is 3,600 repositories an hour, so under an hour for a 1,000-repository install and around eight for 30,000.

Treat those as a floor on the time, not an estimate of it. GitHub also caps content-generating requests at 500 an hour and does not say whether editing a webhook counts as one. If it does, a rotation spends that hour's allowance in about eight minutes and then waits — Lunar backs off when GitHub says so and resumes when the window reopens, so a large fleet can take considerably longer than the arithmetic suggests. Watch **Recent Deliveries** rather than a stopwatch.

Two things follow from repairing on demand rather than sweeping the fleet:

* **A repository loses deliveries until its repair runs** — not only the one that reports the problem. GitHub does not retry, so anything that arrives in that gap never reaches Lunar. Replay them from **Settings → Webhooks → Recent Deliveries → Redeliver**, within GitHub's three-day window.
* **A quiet repository stays stale until it next sees activity**, since nothing reports the problem for it. It repairs on its first delivery afterwards.

`HUB_WEBHOOK_HEAL_MIN_INTERVAL` sets the spacing. Shortening it means going faster than GitHub asks you to, and that allowance is the same one Lunar spends posting commit statuses and PR comments — content-creation limits count the web interface, the REST API and GraphQL together — so a rotation in a hurry can delay the results your team is waiting on. On a large fleet, rotate outside your busiest hours instead. Setting it to `0` turns self-healing off, which leaves the manual procedure below.

{% hint style="info" %}
**Repairing by hand instead.** With `HUB_WEBHOOK_HEAL_MIN_INTERVAL=0`, delete Lunar's hook on each repository through the GitHub API — it is the one whose URL points at your Hub's `/webhooks/github` path. Lunar recreates it on that repository's next sync, which the freshness record gates: at worst `HUB_WEBHOOK_FRESHNESS` (12h by default) after the delete.
{% endhint %}

To rotate a per-app `webhook_secret` in `HUB_GITHUB_APPS` — which takes the place of the chart secret for that App's webhooks — edit that value instead of deleting the chart secret, then `helm upgrade` and restart. Repair is automatic in the same way, scoped to that App's repositories. Doing this one App at a time keeps each blast radius to a single App.
{% endtab %}

{% tab title="GitLab" %}

```bash
kubectl -n lunar delete secret lunar-gitlab-webhook
helm upgrade lunar earthly/lunar -n lunar -f values.yaml
kubectl -n lunar rollout restart deployment/lunar-hub
```

GitLab has no equivalent self-heal, so the hooks have to be deleted by hand. Lunar's hook on a project is the one whose URL points at your Hub's `/webhooks/gitlab` path. Delete it and Lunar recreates it on that project's next sync, carrying the new secret — but the freshness record gates that, so budget up to `HUB_WEBHOOK_FRESHNESS` (12h by default) per project before the replacement appears.

Do not leave projects sitting on the old secret while you wait. GitLab disables a hook after four consecutive failures and **permanently after forty**, and Lunar's automatic re-enable will not help while the secret is still wrong — it re-enables the hook, the next delivery fails, and the count keeps climbing. Delete and recreate promptly rather than waiting out the sync.

To rotate a per-group `webhook_secret` in `HUB_GITLAB_TOKENS`, edit that value instead of deleting the chart secret, then `helm upgrade` and restart. Doing this one group at a time keeps each blast radius to a single group.
{% endtab %}
{% endtabs %}

### Database password (`lunar-db`)

Standard Kubernetes secret rotation. If your Postgres supports multiple active passwords (e.g. RDS password grace), rotate without downtime:

1. Set the new password in Postgres (keeping the old one active).
2. Update the `lunar-db` secret.
3. Restart the Hub.
4. Revoke the old password.

If your Postgres doesn't support multiple active passwords, expect \~1 minute of Hub unavailability during the rotation.

### Other secrets

A few secrets don't get their own recipe above:

| Secret                                                                                       | Managed by | Consumed by                                                                                                 |
| -------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- |
| `<release>-grafana-admin`                                                                    | Chart      | The bundled Grafana + the Hub (only in `grafana.mode: chart`)                                               |
| `<release>-grafana-db` — read-only `grafana_user` role                                       | Chart      | Grafana datasource — whenever dashboards deploy (bundled **or** your own)                                   |
| Your own Grafana login (`HUB_GRAFANA_TOKEN`, or `HUB_GRAFANA_USER` + `HUB_GRAFANA_PASSWORD`) | You        | Hub — only with your own Grafana ([Option B/C](/install/lunar-hub/self-hosted/install-walkthrough#grafana)) |
| Hub licence JWT (example `lunar-hub-licence`)                                                | You        | Hub                                                                                                         |
| Per-scope runtime secrets (`hub.secrets.{collector,cataloger,policy}.secretName`)            | You        | Hub                                                                                                         |

The rotation shape follows the same split as the sections above:

* **Chart-managed** (Grafana admin): `kubectl delete secret <name>` → `helm upgrade` to regenerate → restart the consumer deployment. This does **not** apply to `<release>-grafana-db` — treat it as immutable after install.
* **User-managed** (hub licence JWT, runtime secrets, your own Grafana credentials): update the secret in place (`kubectl apply` or `kubectl create --dry-run=client -o yaml | kubectl apply -f -`) → restart the consumer(s). After rotating your own Grafana credentials, restart the Hub so it picks up the new value — the next `helm upgrade` re-runs the chart's dashboard-deploy Job with it.

To find what consumes an arbitrary secret:

```bash
kubectl -n lunar get deployments -o yaml | grep -B2 '<secret-name>'
```

## Webhook health

Lunar registers a webhook on every tracked repo and re-verifies it periodically, repairing a hook that exists but has stopped delivering:

* **GitLab** auto-disables a hook after 4 consecutive delivery failures (self-heals after a backoff) and **permanently after 40** — which a sustained problem, like a wrongly configured webhook secret or a weeks-long Hub outage, will eventually reach. Lunar detects the disabled state and re-enables the hook by sending a test request.
* **GitHub** never disables a hook for failing, but a repo admin can untick **Active** in the repo's webhook settings. Lunar detects the inactive hook and switches it back on. A hook left on an old [webhook secret](#webhook-secrets) is repaired separately, off the failed delivery itself rather than the re-sync.

Repair happens automatically on the periodic re-sync, at worst one freshness window (`HUB_WEBHOOK_FRESHNESS`, default 12h) plus one sync pass after the hook went dead. It only sticks if the underlying cause is fixed: a GitLab hook whose deliveries keep failing (e.g. a still-wrong webhook secret) will just be disabled again. Events that fired while the hook was dead are not replayed; neither Git platform redelivers them.

## Observability

### Logs

The Hub and Operator log in structured JSON by default (`logging.format: json`). Log level defaults to `info`; raise to `debug` temporarily by editing `values.yaml` and running `helm upgrade`.

The Hub and Operator don't ship logs anywhere themselves — they stream to stdout. To get them into your log aggregator, point a cluster-level log shipper (Fluent Bit, Vector, Datadog Agent, etc.) at the `lunar` namespace.

### Telemetry to Earthly

Hub tenant identity and telemetry destinations come from the signed licence JWT, not from `HUB_TENANT_ID`, `HUB_ELASTIC_*`, or `HUB_OTEL_*` environment variables.

The chart handles the mount and `HUB_LICENCE_FILE` wiring for you. Keep (or override) the licence secret reference in values:

```yaml
hub:
  licence:
    secretName: lunar-hub-licence
    secretKey: hub-licence.jwt
    # optional; defaults to /var/run/secrets/lunar/hub-licence.jwt
    # filePath: /var/run/secrets/lunar/hub-licence.jwt
```

Rotation flow:

* Request a new JWT from Earthly.
* Update the Kubernetes secret value (`hub-licence.jwt`) in place.
* The Hub re-validates every 5 minutes and also enforces the exact `exp` boundary.

### Metrics

The Hub exports request-duration histograms (HTTP and gRPC) via OTLP when the active licence includes `telemetry.otel.endpoint` and `telemetry.otel.token`.

If `telemetry.otel` is omitted, the Hub falls back to writing metrics to a local temp file.

Secure (TLS) OTLP is not implemented yet, so OTLP export runs insecure today.

{% hint style="info" %}
The Hub does not export distributed traces today — only metrics. Trace support is on the roadmap.
{% endhint %}

### Aggregating metrics across replicas

With multiple replicas, be deliberate about how dashboards and alerts aggregate the Hub's job-queue metrics. Queue-depth **gauges** (e.g. `river_jobs`) read from the queue tables in Postgres, which are **shared** by every replica, so each replica reports the same global count — aggregate with **`max`** (or `avg`), never `sum`, which multiplies the backlog by the replica count. Per-replica **counters** are the opposite: `river_insert_count_total` / `river_work_count_total` count work done by *each* replica, so `sum(rate(...))` is the correct cluster-wide throughput.

### Diagnostics bundle

The Hub can produce a diagnostics bundle (Postgres queue state, recent error logs, slow-query stats) for support requests. Generate one with:

```bash
lunar hub get-logs
```

This collects `kubectl` logs from the Hub pods plus Postgres telemetry from the Hub, then writes a `lunar-hub-logs-<timestamp>.tar.gz` in the current directory and prints its path. Run it from a machine with `kubectl` access to the Hub's cluster and your Lunar CLI configured to reach the Hub. Common flags:

* `-n`, `--namespace` — Kubernetes namespace to read Hub pods from (defaults to the current kubectl context's namespace).
* `-o`, `--output` — path to write the bundle to (defaults to `lunar-hub-logs-<timestamp>.tar.gz` in the current directory).
* `--tail` — log lines to capture per container (default: `1000`).

The bundle gathers extra Postgres telemetry when the `pg_stat_statements` extension is enabled — without it the bundle still works, just with less query-performance data. To enable the extension:

* **Amazon RDS / Aurora:** add `pg_stat_statements` to `shared_preload_libraries` in the DB parameter group, then reboot the instance.
* **Self-managed:** add `pg_stat_statements` to `shared_preload_libraries` in `postgresql.conf` and restart, then `CREATE EXTENSION pg_stat_statements;` as a superuser.

The Hub does not require this extension — it's only used by the diagnostics path.

## Scaling

The Hub is stateless — all durable state lives in Postgres and S3 — so it scales **horizontally**: raise `hub.replicaCount` and the replicas coordinate through Postgres (leader election, advisory locks) with no peer-to-peer wiring. Size the connection pool against your Postgres `max_connections` first — see [Replicas and the Postgres connection budget](/install/lunar-hub/self-hosted/install-walkthrough#replicas-and-the-postgres-connection-budget). Add a PodDisruptionBudget (`hub.podDisruptionBudget.enabled: true`) so node drains and rollouts keep a quorum.

Vertical scaling still helps a single busy replica: CPU, memory, and the Postgres pool via `hub.db.maxOpenConns` / `hub.db.maxPoolConns`.

Run throughput scales independently — raise `operator.maxConcurrent` and size run pod resources accordingly. See [`operator.scriptContainerSpec*`](https://github.com/earthly/charts/blob/main/README.md#values-reference) in the chart README.

### Leader election and periodic jobs

Exactly one replica is the elected **leader** at a time; it runs the periodic and maintenance jobs (cleanup, reconcile, backfill). Jobs marked `RunOnStart` fire **immediately whenever leadership is (re)acquired** — not just at process boot — so a failover re-runs them on the new leader. Their handlers must be **idempotent**; the current `RunOnStart` jobs already are. Don't add one whose effect isn't safe to repeat on an arbitrary leadership flip.

## Prioritizing core services over script pods

By default the Operator creates script pods (collectors, policies, catalogers) at the cluster's default priority — the same priority as the Hub, Operator, and Grafana. When those script pods share a node with the core services, a burst of runs can drive the node into memory pressure and the kubelet may evict a *core* pod when it should be shedding an ephemeral script instead.

This matters in two setups:

* **Single-namespace installs** — the Operator runs scripts in the same namespace (and usually the same node group) as the Hub, so core and ephemeral workloads compete for the same memory.
* **Shared scratch namespaces or node groups** — scripts land on nodes shared with other workloads, and you want Lunar's ephemeral pods to yield first under contention.

`operator.scriptPodPriorityClassName` (chart `>= 2.3.0`) sets the [PriorityClass](https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/) on every script pod the Operator creates. Point it at a class whose value sits *below* your core services, and the kubelet evicts scripts first under pressure — the scheduler also won't preempt a higher-priority pod to make room for a script.

### Setup

1. **Create the PriorityClass.** The chart references it by name but does not create it.

   ```yaml
   apiVersion: scheduling.k8s.io/v1
   kind: PriorityClass
   metadata:
     name: lunar-script
   value: -10           # negative → below the default (0) your core pods use
   globalDefault: false
   description: Ephemeral Lunar script pods; evicted before core services.
   ```

   ```bash
   kubectl apply -f lunar-script-priorityclass.yaml
   ```
2. **Point the Operator at it** in `values.yaml`:

   ```yaml
   operator:
     scriptPodPriorityClassName: lunar-script
   ```
3. **Upgrade and restart the Operator** — only script pods created *after* the restart inherit the class:

   ```bash
   helm upgrade lunar earthly/lunar -n lunar -f values.yaml
   kubectl -n lunar rollout restart deployment/lunar-operator
   ```

Leave the Hub, Operator, and Grafana on the cluster default priority — don't set a class on them. The gap between their `0` and the script class's negative value is what orders eviction; you lower scripts rather than raise core services. In a shared scratch node group, pick a value below whatever neighbouring workloads run at so Lunar's scripts are the first thing to go.

{% hint style="warning" %}
PriorityClasses are **cluster-scoped**, and a pod that references one that doesn't exist is rejected at admission — so the Operator will fail to launch scripts until the class is applied. Create the class *before* (or in the same change as) the `helm upgrade`, and delete it only once no workload references it.
{% endhint %}

{% hint style="info" %}
Priority orders *who gets evicted first* — it is not a resource cap. Keep your script pod requests/limits set (see [`operator.scriptContainerSpec*`](https://github.com/earthly/charts/blob/main/README.md#values-reference) in the chart README) so a single run can't consume a whole node before the kubelet reacts. Priority decides the order; requests and limits decide the ceiling.
{% endhint %}

## Operating the GitLab merge gate

If you run [the merge gate](/install/git-platforms/gitlab#merge-gate) on GitLab Ultimate, two of its properties are operational rather than install-time:

* **It is fail-closed.** A Hub outage leaves gated merge requests blocked. That is the correct default for a compliance control, but it means a Lunar incident becomes a merge freeze.
* **The lever is on the GitLab side.** To unblock merges during an incident, a project Owner disables **Status checks must succeed** in the project's merge-request settings. Lunar re-enables it on the next sync, so this buys time rather than being an opt-out. A project that should be permanently ungated belongs out of Lunar's configuration instead.

Individual blocked merge requests do not need an incident response. An authorized engineer can comment [`/lunar bypass: <reason>`](/docs/pr-comments) on the merge request, which is recorded and applies to that commit only.

## Backup and disaster recovery

Lunar's authoritative state lives in the systems you already back up:

* **Postgres** — use your existing Postgres backup process. Everything the Hub cares about (components, policies, run history, queue state) is here.
* **S3 buckets** — enable object versioning on both buckets and, if compliance requires, cross-region replication. Lost resource archives cause re-building the cache of catalogers, collectors, and policies; lost log archives mean lost history. Neither impedes Hub operation.
* **Hub local scratch** — each Hub pod's `/var/lib/lunar` is an ephemeral `emptyDir` (re-extracted runtimes and cached bundles; the authoritative copy is in S3). It is per-pod and regenerable, so there's nothing to back up.
* **Kubernetes secrets** — keep your Git platform credential (the GitHub App PEM or the GitLab group access token), DB credentials, auth token, and webhook secret in your organization's secret-management system of record. Losing them means re-provisioning.

## Uninstalling

```bash
helm uninstall lunar --namespace lunar
```

Helm removes all resources the chart created: the Hub and operator deployments, services, ingress, RBAC, and service accounts.

**Not removed automatically:**

* The `lunar` namespace itself (and, if used, the Operator's execution namespace).
* Kubernetes secrets you created manually (e.g. `lunar-db`, `lunar-github-app`, `lunar-gitlab-token`, `lunar-hub-licence`).
* **Anything Lunar provisioned on your GitLab projects** — webhooks, the `Earthly Lunar` status check, and the **Status checks must succeed** setting. Uninstalling the Hub does not undo them, and with the Hub gone nothing answers the status check, so gated merge requests stay blocked. Before uninstalling, disable that setting on the affected projects.
* Chart-managed secrets (`<release>-auth-token`, `<release>-github-webhook`, `<release>-gitlab-webhook`, `<release>-grafana-admin`). These carry `helm.sh/resource-policy: keep` so a re-install reuses the same values — by design. Delete them explicitly if you want fresh credentials on the next install.
* Your Postgres database, S3 buckets, or their contents.
* The GitHub App. Webhooks previously registered by this Hub are tagged with the Hub's instance ID (`earthly-lunar-<tenant-id-from-licence>` as a URL fragment); they remain on your repos until you remove them manually or install a fresh Hub with the same tenant ID, which will clean up its own stale webhooks on next config pull.

To fully tear down:

```bash
helm uninstall lunar --namespace lunar
kubectl -n lunar delete secret lunar-db lunar-github-app lunar-gitlab-token \
  lunar-hub-licence \
  lunar-auth-token lunar-github-webhook lunar-gitlab-webhook lunar-grafana-admin \
  --ignore-not-found
kubectl delete namespace lunar

# Externally:
#   - Drop the Postgres database
#   - Delete or empty the S3 buckets
#   - Uninstall the GitHub App from your organization
#   - Revoke the GitLab group access token, and remove the status check and
#     "Status checks must succeed" setting from any gated projects
```

## Getting help

* [Chart values reference](https://github.com/earthly/charts/blob/main/README.md#values-reference)
* [Chart source](https://github.com/earthly/charts)
* [Lunar source](https://github.com/earthly/lunar)
* For enterprise onboarding or production sizing guidance, [contact the Earthly team](https://earthly.dev/earthly-lunar/demo).


# Lunar Dedicated

Lunar Dedicated is a single-tenant Lunar install that Earthly provisions, operates, and upgrades for you — the overview, what your team does to bring one up, and the private paths in and out of it.

Lunar Dedicated is a fully managed, single-tenant Lunar install. Earthly provisions it in an AWS account dedicated to you and owns the whole lifecycle: upgrades, patching, monitoring, backup and restore. If you would rather run the Hub on your own Kubernetes, see [Self hosted](/install/lunar-hub/self-hosted) instead.

{% hint style="info" %}
Earthly runs the infrastructure; you keep custody of your data and your credentials. The CLI and CI tracer are installed and configured exactly as they are on a self-hosted Hub.
{% endhint %}

## In order

1. [**Overview**](/install/lunar-hub/dedicated/overview) — how a Dedicated install is put together, the ways you can reach it, what Earthly needs from you, and how it handles your data and secrets. Read this first, and hand it to your security reviewer.
2. [**Setup**](/install/lunar-hub/dedicated/setup) — everything your side does, in order: the questionnaire, your depositor account, your Git platform credential, and the secret deposit. This is the page to work from.
3. [**PrivateLink to Your Hub**](/install/lunar-hub/dedicated/privatelink-inbound) — the recommended way for your CI runners, CLI users, and browsers to reach the install, and skippable only if your CI runs on shared hosted runners. Your side creates one interface endpoint and two DNS records. Settle how your Git platform will deliver webhooks early on, because that part can carry a lead time you don't control.
4. [**PrivateLink to Your Internal Systems**](/install/lunar-hub/dedicated/privatelink-outbound) — only if something your install must reach isn't on the public internet: a self-managed Git platform, or a collector target such as an internal registry, ticketing system, or API. It comes last in this list, but start it first. It needs a DNS change and an AWS domain-ownership check on your side, which makes it the longest-lead-time item in onboarding. You can forward the page to your networking team on its own.

## Before you start

Two neighboring sections apply to Dedicated installs as well, so they sit outside this one:

* [**Git Platforms**](/install/git-platforms) covers creating the credential Lunar authenticates with, on GitHub or GitLab. You create it and you [deposit it yourself](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets), so it never passes through Earthly. The Hub-configuration sections of those pages are for self-hosted installs only.
* [**Lunar CI Tracer**](/install/ci-tracer) covers build-time data, if you want it. The tracer installs on your CI runners rather than in your install, so it is independent of onboarding and can be added at any point afterwards.

Still deciding whether Dedicated is the right fit? The [Overview](/install/lunar-hub/dedicated/overview) covers the trade-offs, or [book a demo](https://earthly.dev/book-demo/) and Earthly will walk your team through the model.


# Overview

How a Lunar Dedicated install works — a single-tenant install that Earthly provisions, operates, and upgrades end-to-end in a dedicated AWS account, reachable over private networking or the internet.

Lunar Dedicated is a fully managed, single-tenant Lunar install. Earthly provisions, operates, and upgrades it end-to-end in an AWS account dedicated to you, and you reach it over private networking or, where your CI can't, over the internet. It's for teams that want Lunar's guardrails without running the [Hub](/install/lunar-hub/self-hosted/overview) themselves.

This page explains how it works, who it fits, and what Earthly needs from you to stand one up. [Setup](/install/lunar-hub/dedicated/setup) is the hands-on companion covering what your team actually does.

{% hint style="info" %}
Lunar Dedicated is a managed offering, distinct from the self-hosted [Lunar Hub](/install/lunar-hub/self-hosted/overview). With Dedicated, Earthly runs the infrastructure; with the self-hosted Hub, you do. The CLI and CI tracer are installed and configured the same way in both.
{% endhint %}

## How it works

```mermaid
flowchart LR
  Git["GitHub / GitLab"]

  subgraph Earthly["Earthly (management account)"]
    Mgmt["Deploy and operate tooling"]
  end

  subgraph Dedicated["Your dedicated AWS account"]
    Hub["Lunar Hub"]
    WH["Webhook listener"]
    Data[("Postgres · S3 · secrets")]
    Hub --> Data
  end

  subgraph You["Your network"]
    CI["CI runners · Lunar CLI"]
  end

  Mgmt -- "assume-role (IAM)" --> Dedicated
  CI -- "internet or PrivateLink —<br/>you choose" --> Hub
  Git -- "webhooks" --> WH
```

* **One dedicated AWS account.** The strongest isolation AWS offers: a hard billing and security boundary, with nothing else running inside it.
* **Earthly operates it by assuming an IAM role.** Management is identity-based, so Earthly needs no VPN or peering to run your install.
* **You choose how the hub is reached.** Over the internet on your own hostname, behind TLS and token authentication, or privately over [PrivateLink](/install/lunar-hub/dedicated/privatelink-inbound). Joining the two networks directly (peering / Transit Gateway) is [coming soon](#how-you-connect). Whether the webhook listener stays public depends on where your Git platform lives: a cloud-hosted one delivers from the internet, while a platform in your own network can deliver privately.
* **Your data stays in the install.** Authoritative state (Postgres), run inputs and outputs (S3), and secrets all live in your dedicated account and region. Earthly stores none of your data centrally.
* **Earthly manages the whole lifecycle.** Provisioning, version upgrades, patching, and monitoring, on a [maintenance schedule](#maintenance-and-upgrades) you choose.

## Requirements

Lunar Dedicated fits cleanly when the following are true:

| Requirement                                         | Detail                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Reachable CI**                                    | The Lunar CLI and [CI tracer](/install/ci-tracer/github-actions-self-hosted) must run somewhere that can reach the hub. With internet access that's anywhere, including GitHub-hosted runners; with private access it means self-hosted runners in your network, or runners with VPN/PrivateLink access. On GitLab there is no tracer to place ([tracing is coming soon](/install/ci-tracer/gitlab-ci)), but the CLI still needs that reachability. |
| **A network to connect from** (private access only) | An AWS VPC that can reach a PrivateLink endpoint. Not needed if you reach the hub over the internet.                                                                                                                                                                                                                                                                                                                                                |
| **An outbound allowlist you can permit**            | The install needs egress for operational telemetry, container image pulls, and AWS APIs.                                                                                                                                                                                                                                                                                                                                                            |
| **A single region**                                 | Each install lives in one AWS region.                                                                                                                                                                                                                                                                                                                                                                                                               |
| **Git platform credentials**                        | On GitHub, a [GitHub App](/install/git-platforms/github) you create and install on the org(s) and repositories Lunar should monitor. On GitLab, a [group access token](/install/git-platforms/gitlab#authentication) per top-level group. If your instance is self-managed and not reachable from the public internet, Lunar also needs a [private path to it](/install/lunar-hub/dedicated/privatelink-outbound).                                  |

{% hint style="info" %}
**Coming soon.** Joining your network to the install directly (VPC peering or Transit Gateway), connecting privately with no existing AWS footprint (site-to-site VPN), and multi-region installs are on the roadmap.
{% endhint %}

{% hint style="info" %}
**Need a fully air-gapped, no-egress install?** Dedicated isn't air-gapped; it requires outbound egress to operate. For an air-gapped environment, the [self-hosted Lunar Hub](/install/lunar-hub/self-hosted/overview) is a better fit. [Book a demo](https://earthly.dev/book-demo/) to work with us.
{% endhint %}

## How you connect

Two ways to reach your hub are available today, with a third on the way. PrivateLink is the one Earthly recommends wherever your CI can use it; the deciding factor is usually where your CI runs.

|                      | **Internet**                                                                | **PrivateLink**                                       | **Joined network** (peering / Transit Gateway)                |
| -------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------- |
| Availability         | Available now                                                               | Available now                                         | **Coming soon**                                               |
| What it is           | The hub on your own hostname, behind TLS and token authentication           | A private one-way "door" from your network to the hub | Your network and the install's network are routed together    |
| Reaching the hub     | From anywhere, including GitHub-hosted runners                              | Through one private endpoint you create               | Over the joined network, at a private address                 |
| Address coordination | None                                                                        | None (address ranges can overlap freely)              | Address ranges must not overlap                               |
| Your setup effort    | None                                                                        | Create one endpoint and two DNS records               | Accept the join and add routes (usually your networking team) |
| Best when            | Your CI runs on shared hosted runners, which can't reach a private endpoint | **Recommended.** Your CI runs in a VPC you control    | You already run a Transit Gateway                             |

{% hint style="info" %}
Your install reaches outwards as well as inwards. The Hub calls your Git platform to read repositories, register webhooks, and post results; collectors, catalogers, and policies run *inside* your install and reach the systems they gather data from. For a dedicated customer any of those may be internal: a self-managed GitHub Enterprise Server or GitLab instance, registries, ticketing, internal APIs. That's why connectivity is two-way, and why onboarding asks where your CI runs and what your install needs to reach.
{% endhint %}

The two directions are set up independently, and you may need only one of them:

* **Reaching the hub**: how your CI runners, CLI users, and browsers get to your install. Choose from the options above. Over the internet there's nothing for you to do; PrivateLink needs one interface endpoint and two DNS records from you, covered in [PrivateLink to Your Hub](/install/lunar-hub/dedicated/privatelink-inbound).
* **Lunar reaching your internal systems**: needed if anything your install must reach sits off the public internet, such as a self-managed Git platform or an internal registry, ticketing system, or API. You publish each target as a PrivateLink endpoint service; see [PrivateLink to Your Internal Systems](/install/lunar-hub/dedicated/privatelink-outbound). **This one has a long lead time, so start it first.**

## What Earthly needs from you

These answers shape your install:

| What                    | Why                                                                                                                                                                                                                                                                                                                                                                       |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Region**              | Where the install (and your data) lives                                                                                                                                                                                                                                                                                                                                   |
| **Hub access**          | Internet or PrivateLink, and where your CI runs. Say so if you would rather join networks directly, so Earthly can tell you where that sits on the roadmap                                                                                                                                                                                                                |
| **Internal targets**    | Whether anything Lunar must reach sits off the public internet (a self-managed Git platform, or collector targets like internal registries, ticketing, or APIs), and if so, how many distinct hostnames. Drives [PrivateLink to Your Internal Systems](/install/lunar-hub/dedicated/privatelink-outbound), the longest-lead-time item in onboarding                       |
| **DNS**                 | Use Earthly's subdomain (`you.dedicated.earthly.dev`), or delegate a subdomain of your own (e.g. `lunar.yourco.com`) for Earthly to manage                                                                                                                                                                                                                                |
| **Git platform**        | Which one, and where it lives: for [GitHub](/install/git-platforms/github), the App ID and installation ID plus the org(s)/repos it's installed on; for [GitLab](/install/git-platforms/gitlab), the top-level group(s) and, if self-managed, your instance hostname. The credential itself you [deposit](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets) |
| **Sizing**              | Rough org/repo count and CI volume, which maps to a t-shirt size (final sizing confirmed together)                                                                                                                                                                                                                                                                        |
| **Maintenance windows** | A recurring weekly app window and a monthly infra window, in your timezone                                                                                                                                                                                                                                                                                                |
| **Contacts**            | An operations contact (for maintenance notices) and a security contact (for incidents)                                                                                                                                                                                                                                                                                    |
| **WAF** (if required)   | Whether your security review requires a web-application firewall in front of a public webhook listener. It isn't part of the standard install, so raise it and Earthly will scope it with you                                                                                                                                                                             |

## Setup

Earthly handles initial provisioning and setup. This includes account creation, infrastructure provisioning, bringing up your hub, every upgrade (within your chosen window), version selection, monitoring, backup/restore, and clean offboarding.

However, we do need some help from your end.

### What you do

Summarized here; [Setup](/install/lunar-hub/dedicated/setup) has the commands and is the page to work from.

1. **Send your** [**questionnaire answers**](#what-earthly-needs-from-you)**.** Earthly replies with your coordinates packet (tenant, region, dedicated account ID, deposit role ARN, deposit ExternalId, and your webhook URL) and starts building.
2. **Start** [**private access to your internal systems**](/install/lunar-hub/dedicated/privatelink-outbound), if your Git platform or any collector target isn't on the public internet. Longest lead time in onboarding, so start it first.
3. [**Nominate your depositor account**](/install/lunar-hub/dedicated/setup#step-2-identify-your-depositor-account): the AWS account you'll deposit secrets from (there's a path for teams without one).
4. [**Set up your Git platform**](/install/lunar-hub/dedicated/setup#step-3-set-up-your-git-platform) and send Earthly the identifiers: on GitHub the App ID, installation ID, and org; on GitLab the top-level group(s) and your instance hostname if you're self-managed.
5. [**Deposit your secrets**](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets): a \~30-minute terminal task once the packet and the credential exist. Secrets are written **directly into your dedicated account's** secret store through a write-only role, never through Earthly's systems.
6. [**Go live**](/install/lunar-hub/dedicated/setup#go-live): Earthly verifies the install end-to-end and hands over your access token, plus a Grafana login if your install is reachable over the internet; you load workload secrets, finish DNS if you delegated a subdomain, and create your inbound endpoint if you chose PrivateLink.

None of this needs a meeting; most teams have all of it ready before their first call with Earthly.

## Maintenance and upgrades

The Earthly team manages Lunar Hub updates for you; you choose *when* upgrades land. There are two recurring maintenance windows:

* **A weekly app window** for routine Lunar version bumps (a rolling hub restart).
* **A monthly infra window** for node patches, add-on updates, and the occasional infrastructure upgrade.

Every Dedicated install runs the hub redundantly, so routine upgrades roll through without taking the service down; expect at most a brief interruption as connections move. Planned windows are excluded from the uptime clock. Critical security patches and forced version deprecations can land out-of-window, with notice.

**On GitLab, one recurring task stays with you.** Group access tokens carry a fixed expiry, and there is no overlap period where old and new both work. Create the replacement in GitLab and [deposit it](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets) ahead of the expiry date, using the same command as at setup, then revoke the old one. See [expiry and rotation](/install/git-platforms/gitlab#expiry-and-rotation). If a token lapses, Lunar stops posting results and stops reacting to webhooks for that group. GitHub App credentials do not expire this way.

## Security and data

How a Dedicated install handles isolation, data, secrets, and audit:

* **Exclusive, isolated account.** Your install runs alone in a dedicated AWS account, with no other workloads.
* **Data residency.** Authoritative state (Postgres), run inputs/outputs (S3), and secrets all live in your dedicated account and region. Earthly stores none of your data centrally.
* **Secrets are encrypted at rest, and Earthly never holds them.** You [deposit](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets) your Git platform credential (the GitHub App private key, or the GitLab group access token) into your install's own secret drop over a write-only role, and your workload secrets (API keys) go straight to your hub via the CLI. Neither passes through Earthly's central systems, and both live encrypted inside your install.
* **Full audit visibility.** Every action Earthly's roles take is recorded in AWS CloudTrail.
* **Defined data lifecycle.** Retention windows for queue history, catalog history, and the Hub's working files are set for your install; see [Data Retention](/configuration/hub-configuration/data-retention) for what each window covers and what it does not. On exit, Earthly returns your data (a portable database dump plus your storage buckets) and **provably deletes** the install by destroying its encryption keys, with the deletion recorded in CloudTrail.
* **Operational telemetry is required.** The install sends metrics and logs back to Earthly so it can monitor the install and meet the SLA. This covers operational data only (never your source code, data, or secrets), and Earthly details exactly what it includes in your security review. If you need telemetry fully off, the [self-hosted Lunar Hub](/install/lunar-hub/self-hosted/overview) is a better fit; [book a demo](https://earthly.dev/book-demo/).

{% hint style="warning" %}
**Custody is audited.** Your secrets are encrypted with a key that lives inside your account and is readable only by the hub. Routine operation can't read your secrets, and any access that could is a deliberate, audited action. It is not a cryptographic guarantee that Earthly *cannot* read them. If you need hard zero-knowledge, the [self-hosted Lunar Hub](/install/lunar-hub/self-hosted/overview) is the better fit; [book a demo](https://earthly.dev/book-demo/).
{% endhint %}

## What's included

* A single-tenant, fully managed Lunar install in a dedicated AWS account
* Hub access over the internet, or privately via PrivateLink
* A redundant hub, so planned upgrades roll through without downtime
* A single region per install
* Full lifecycle management: provisioning, upgrades, patching, monitoring, backup/restore, and clean offboarding

## Next steps

[Setup](/install/lunar-hub/dedicated/setup) is where your team picks it up: the questionnaire, your depositor account, your Git platform credential, and the secret deposit.

If anything Lunar must reach sits off the public internet, such as a self-managed Git platform or a collector target, start [PrivateLink to Your Internal Systems](/install/lunar-hub/dedicated/privatelink-outbound) in parallel. It's the longest-lead-time item in onboarding.

Earthly is also glad to walk through the model live, answer your security team's questions, and map out what onboarding looks like for your environment.

<a href="https://earthly.dev/book-demo/" class="button primary" data-icon="calendar">Book a demo</a>


# Setup

What your team does to bring up a Lunar Dedicated install, covering connectivity, the depositor account, your Git platform credential, and the secret deposit.

Earthly provisions and operates your entire install. This page is everything your side does, in order, with the reasoning behind each step.

Read the [Overview](/install/lunar-hub/dedicated/overview) first if you want the model, the security posture, and what the service includes. This page is the hands-on companion.

## Your steps at a glance

| Step                                                                          | Who does it                                  | When                                               |
| ----------------------------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------- |
| [Questionnaire answers](#first-the-questionnaire)                             | Whoever owns the rollout                     | First (everything else keys off them)              |
| [1. Private connectivity](#step-1-private-connectivity-if-you-need-it)        | Your AWS/network team                        | **Start immediately**; this one can have lead time |
| [2. Identify your depositor account](#step-2-identify-your-depositor-account) | Your AWS admin                               | Any time before the deposit                        |
| [3. Set up your Git platform](#step-3-set-up-your-git-platform)               | Your GitHub org admin, or GitLab group owner | Any time before the deposit                        |
| [4. Deposit your secrets](#step-4-deposit-your-secrets)                       | Either, from a terminal                      | Last (needs the packet and the credential)         |

Steps 2 and 3 are quick and independent. **Step 1 is the one that determines your timeline.** It involves DNS changes on your side, plus an AWS domain-ownership check or a request to your Git platform vendor depending on which direction you need, so start it the day you send your answers even though it's needed last.

Earthly builds the install in parallel. The only early input it needs from you is your depositor account ID ([step 2](#step-2-identify-your-depositor-account)).

## First, the questionnaire

Your answers to the [onboarding questionnaire](/install/lunar-hub/dedicated/overview#what-earthly-needs-from-you) parameterize the install: region, hub access, internal targets, DNS, sizing, maintenance windows, contacts. Send them to Earthly whenever they're settled; nothing waits on a meeting.

Earthly replies with your **coordinates packet**: tenant name, region, dedicated account ID, deposit role ARN, deposit ExternalId, and your webhook URL. Several steps below need values from it.

## Step 1 — Private connectivity, if you need it

Traffic crosses between your network and the install in both directions, and each direction is its own piece of setup. You may need one, both, or neither.

| Direction                                                            | You need it when                                        | What you build                              |
| -------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------- |
| [Lunar reaching your systems](#lunar-reaching-your-internal-systems) | something Lunar must reach isn't on the public internet | an endpoint service in front of each system |
| [Reaching the hub](#your-ci-and-browsers-reaching-the-hub)           | your CI runs in a VPC you control (recommended)         | one interface endpoint and two DNS records  |

Both are AWS PrivateLink, and both are your networking team's work. Each has a page written to be forwarded on its own. Work out which apply the day you send your questionnaire answers, because either can carry lead time you don't control. That's why this is step 1 even though nothing needs it until the end.

### Lunar reaching your internal systems

The Hub calls your Git platform to read repositories, register webhooks, and post results. Collectors, catalogers, and policies run *inside* your install and reach the systems they gather data from, such as registries, ticketing, and internal APIs. If any of those targets isn't reachable from the public internet, Lunar needs a private path to it.

**This includes your Git platform itself.** A self-managed GitHub Enterprise Server or GitLab instance that only resolves inside your network is one of these targets, and an easy one to overlook: the Hub needs the private path before any collector does.

**Start here if that applies to you:** [**PrivateLink to Your Internal Systems**](/install/lunar-hub/dedicated/privatelink-outbound)**.** It covers publishing your internal service as an AWS PrivateLink endpoint service, allowlisting Earthly's dedicated account, and associating a verified DNS name so Lunar reaches your service by its real hostname with working TLS.

{% hint style="warning" %}
**This can take time.** The AWS work is straightforward, but it includes publishing a DNS record and waiting for AWS to verify domain ownership, which means a DNS change on your side (and potentially your change-management process). If DNS changes queue behind a change window, that wait alone can add days. Start this in parallel *first*, even though it isn't needed until the end.
{% endhint %}

Not sure it applies? The [Do you need this?](/install/lunar-hub/dedicated/privatelink-outbound#do-you-need-this) questions at the top of that page take two minutes and can rule the work out entirely: public targets need nothing, and an internet-facing target can often just allowlist the install's single outbound IP.

### Your CI and browsers reaching the hub

Earthly recommends publishing the install privately, so your CI runners, CLI users, and browsers reach it from your own VPC with nothing exposed to the internet. The alternative is a public hostname behind TLS and token authentication, which needs nothing from you and is the only option when your CI runs on shared hosted runners.

**Start here if that applies to you:** [**PrivateLink to Your Hub**](/install/lunar-hub/dedicated/privatelink-inbound)**.** It covers creating the interface endpoint, the two DNS records that point your install's hostnames at it, and reaching the UI from laptops rather than just from CI.

{% hint style="warning" %}
**Settle the webhook path first.** Your Git platform delivers webhooks to the hub, and it isn't on your runner network, so the endpoint your CI uses won't carry that traffic. A platform inside your own network can deliver through an endpoint of its own, and a cloud-hosted one delivers from the internet, which means that one listener stays public. A vendor-managed single-tenant platform is the case to start early: the private connection is something you request from your vendor, on a timeline you don't control.
{% endhint %}

Not sure it applies? The [Do you need this?](/install/lunar-hub/dedicated/privatelink-inbound#do-you-need-this) questions at the top of that page will settle it, and the deciding factor is usually where your CI runs: self-hosted runners in AWS are the natural fit, while shared hosted runners can't use a private endpoint at all.

## Step 2 — Identify your depositor account

During setup, your secrets (your Git platform credential, the encryption key, and the webhook secret) are written **directly into your dedicated account's** secret store, never through Earthly's systems. They're deposited by assuming a **write-only deposit role** in the dedicated account. Earthly creates that role while provisioning your account, with your depositor principal baked into its trust policy. The trust policy is fixed at that point, which is why your account ID is the one input Earthly needs early.

**Send Earthly the 12-digit ID of the AWS account you will deposit from.** Earthly trusts that account principal (`arn:aws:iam::<YOUR_ACCOUNT_ID>:root`) plus a deposit-specific ExternalId (included in your coordinates packet).

That does **not** grant everyone in the account access; your IAM administrator still chooses which role or user may call `sts:AssumeRole`. Attach this caller-side policy to the role, user, or permission set your team will use:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "sts:AssumeRole",
    "Resource": "arn:aws:iam::<DEDICATED_ACCOUNT_ID>:role/<TENANT>-secret-deposit"
  }]
}
```

The exact deposit-role ARN for the `Resource` line is also in your coordinates packet. It's fine to prepare the policy with a placeholder and tighten it when the packet arrives.

Notes that save a support round-trip:

* Trusting the account's `:root` ARN is **not** logging in as the AWS root user. The root user cannot assume roles; use a normal IAM or SSO principal.
* Earthly does not trust a specific SSO-generated role ARN, so recreating a permission set does not silently break the deposit trust. Your caller-side policy remains the gate.
* If an SCP or permission boundary blocks cross-account `sts:AssumeRole`, your AWS administrator must allow this one deposit-role ARN.

{% hint style="info" %}
**No AWS account?** Tell Earthly along with your questionnaire answers. When you're ready to deposit, Earthly will mint **one-hour temporary credentials** for the write-only deposit role and deliver them over a secure channel.
{% endhint %}

## Step 3 — Set up your Git platform

Lunar authenticates to your Git platform with a credential that **you** create, on the groups, orgs, and repos it should monitor. Earthly never holds org-admin access, and never handles the credential itself.

[Git Platforms](/install/git-platforms) covers what differs between the two platforms. The Hub-configuration sections of those pages are for self-hosted installs only, since Earthly configures the Hub for you.

{% tabs %}
{% tab title="GitHub" %}
Create a GitHub App and install it on your org.

* Use the [hosted setup tool](https://earthly.dev/lunar/github-app-setup/), or [create it by hand](/install/git-platforms/github#manual-setup-alternative) on GitHub Enterprise Server, or if your security review needs every permission spelled out first. The [permissions it is granted](/install/git-platforms/github#permissions) are listed for that review.
* Set the **webhook URL** from your coordinates packet (`https://hub.<TENANT>.<your-dedicated-domain>/webhooks/github`). It's fine that the install isn't live yet.
* Download the App's **private key** (`.pem`). It's deposited in step 4; it never goes to Earthly, in any channel.
* Leave the App's **"Webhook secret" field empty.** Lunar registers and secures its own repository webhooks from inside your install; the App-level webhook carries no traffic.

**Send Earthly:** the App ID, the installation ID, and the org(s) it's installed on. Those are identifiers, not secrets.
{% endtab %}

{% tab title="GitLab" %}
Create a [service account and token](/install/git-platforms/gitlab#create-the-token). On a self-managed or Dedicated instance, one **instance service account** made a Maintainer of every top-level group Lunar should serve gives you a single token for everything. On GitLab.com, create a **group service account** and token on each top-level group instead.

* Give the token the `api` scope and the `Maintainer` or `Owner` role.
* Pick an expiry and set a calendar reminder for it now. GitLab tokens expire on a fixed date, with no overlap period. See [Rotations and changes](#rotations-and-changes).
* Copy the token when GitLab shows it; it is displayed once. It's deposited in step 4 and never goes to Earthly.
* **There is no webhook URL to set, and no webhook secret to paste back.** Lunar registers a webhook on each project itself and generates the signing secret inside your install, so unlike GitHub there's nothing to configure on the GitLab side.

**Send Earthly:** your instance hostname if you're self-managed, and for group tokens the top-level group path each one covers. Those are identifiers, not secrets.

{% hint style="warning" %}
**Self-managed instances must be declared.** Lunar decides whether a host is GitLab from configuration, so if you don't tell Earthly your instance hostname it will be treated as GitHub and every operation against it fails in confusing ways. `gitlab.com` needs no declaration.
{% endhint %}
{% endtab %}
{% endtabs %}

## Step 4 — Deposit your secrets

Run this from a terminal once you have your coordinates packet and your Git platform credential. Expect about 30 minutes end to end. You'll need the **tenant name, region, deposit role ARN, and deposit ExternalId** from the packet. Earthly is glad to join a call if you need help.

{% hint style="info" %}
The deposit ExternalId unlocks only the write-only deposit role. It is not Earthly's deploy ExternalId, and it grants nothing else.
{% endhint %}

Use an AWS profile for a principal carrying the caller-side policy from step 2:

{% tabs %}
{% tab title="GitHub" %}

```bash
AWS_PROFILE=<your-profile> lunar setup bootstrap \
  --tenant <TENANT> --region <REGION> \
  --github-app-pem <path/to/app.pem> \
  --deposit-role-arn <DEPOSIT_ROLE_ARN> \
  --external-id <DEPOSIT_EXTERNAL_ID>
```

{% endtab %}

{% tab title="GitLab" %}
Write the token to a file first, so it never lands in your shell history:

For an **instance service account** token (self-managed or Dedicated), one run covers the whole instance:

```bash
AWS_PROFILE=<your-profile> lunar setup bootstrap \
  --tenant <TENANT> --region <REGION> \
  --gitlab-host <GITLAB_HOSTNAME> \
  --gitlab-token <path/to/service-account-token> \
  --deposit-role-arn <DEPOSIT_ROLE_ARN> \
  --external-id <DEPOSIT_EXTERNAL_ID>
```

For **group access tokens** (GitLab.com), name the group instead and **repeat once per top-level group** — Lunar keys these tokens by group, so each one is deposited under its own group path:

```bash
AWS_PROFILE=<your-profile> lunar setup bootstrap \
  --tenant <TENANT> --region <REGION> \
  --gitlab-group <TOP_LEVEL_GROUP> \
  --gitlab-token <path/to/group-access-token> \
  --deposit-role-arn <DEPOSIT_ROLE_ARN> \
  --external-id <DEPOSIT_EXTERNAL_ID>
```

Beyond naming the deposit, your instance hostname is configuration, not a secret. Earthly applies it from the identifiers you sent in [step 3](#step-3-set-up-your-git-platform).
{% endtab %}
{% endtabs %}

If Earthly issued one-hour temporary credentials instead (the no-AWS-account path), export the three values in your shell and drop the last two flags; the credentials already *are* the deposit role:

```bash
export AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=… AWS_SESSION_TOKEN=…
lunar setup bootstrap \
  --tenant <TENANT> --region <REGION> \
  --github-app-pem <path/to/app.pem>
```

What the command does:

* Generates the **encryption key** and **webhook secret** inside this flow and deposits them write-once. Neither is ever printed — the install consumes them directly.
* Deposits the Git platform credential you supplied: the GitHub App PEM, or the GitLab token.
* Verifies each write from the write response. The deposit role is **write-only by design**: it cannot read the secrets back, and the setup flow never returns them to Earthly.
* Leaves nothing to paste back on either platform: Lunar registers repository and project webhooks itself and manages their signing secrets inside your install.

**If you get** `AccessDenied`, in order of likelihood:

1. Your caller doesn't have `sts:AssumeRole` on the deposit-role ARN.
2. Deposit ExternalId typo; it must match the packet exactly.
3. An SCP or permission boundary in your org blocks `sts:AssumeRole` to accounts outside your organization. Loop in whoever owns your SCPs.
4. On the no-AWS-account path: the one-hour credentials expired. Ask Earthly to mint a fresh set; it takes seconds.

## Go live

Earthly completes the install, verifies it end-to-end, and hands you the **hub access token** over a secure channel. Treat it like a root credential. On an internet-reachable install you also get a **Grafana login**; on a private install the UI has no login and is read-only to anyone who can reach your endpoint.

Then, on your side:

**Load your workload secrets**: the API keys your catalogers, collectors, and policies need. These go straight to your hub via the CLI, never through Earthly's systems:

```bash
lunar secret set DATADOG_API_KEY --scope collector   # value read from stdin
```

**Finish DNS, if you delegated a subdomain.** Add the NS records Earthly provides to hand off the zone. Earthly then manages every record and the TLS certificate under it.

**Complete your hub-access step, if you chose PrivateLink.** Create the inbound endpoint and coordinate with Earthly, usually via your networking team. Over the internet there's nothing to do.

**Confirm private connectivity, if you set it up in step 1.** Earthly runs a verification pass from a real Lunar workload (hostname resolution, TLS with hostname verification, an authenticated application request) and only points your install at the internal hostname once all of it passes.

## Rotations and changes

**GitHub App PEM.** Re-run [step 4](#step-4-deposit-your-secrets) with the new file. The encryption key and webhook secret will not rotate, but the PEM will.

**GitLab token.** GitLab tokens carry a fixed expiry, and there is no overlap period where old and new both work, so replacing one is a cutover rather than a rollover. Create the replacement in GitLab, re-run [step 4](#step-4-deposit-your-secrets) with it ahead of the expiry date, then revoke the old one. See [expiry and rotation](/install/git-platforms/gitlab#expiry-and-rotation).

{% hint style="warning" %}
If a GitLab token lapses, Lunar stops posting results and stops reacting to webhooks for the groups it served. Because the token is read at startup, nothing fails loudly to tell you. Set the calendar reminder when you create the token, not later.
{% endhint %}

If you used the AWS-less fallback, contact Earthly before any credential rotation; fresh one-hour credentials are minted for the re-run and nothing else changes.

**Adding or removing an internal target.** Publish the new endpoint service following [PrivateLink to Your Internal Systems](/install/lunar-hub/dedicated/privatelink-outbound) and send Earthly the same six values. Earthly makes a small, reviewed change on its side, with no downtime for your install.


# PrivateLink to Your Hub

Reach your Lunar Dedicated hub over a private AWS PrivateLink path from your own VPC, by creating one interface endpoint and two DNS records.

Your CI runners, CLI users, and browsers all need to reach your Lunar Dedicated install, and PrivateLink is the recommended way to let them. The hub API and the UI answer only inside the networks you connect to them, and nothing is published to the internet. The alternative, a public hostname behind TLS and token authentication, is there for teams whose CI runs on shared hosted runners and so cannot reach a private endpoint.

This page is the setup guide for that path. It's written for whoever runs your AWS networking. You can forward it on its own.

{% hint style="info" %}
This page covers reaching the hub **in**. Lunar reaching **out** to your internal systems (a self-managed Git platform, an internal registry or API) is a separate, independent setup, covered in [PrivateLink to Your Internal Systems](/install/lunar-hub/dedicated/privatelink-outbound). Many installs need both.
{% endhint %}

## Do you need this?

Three questions to decide.

**1. Does your CI run in a network you control?** PrivateLink reaches your install from a VPC, so self-hosted runners in AWS are the natural fit. Shared hosted runners (github.com's, gitlab.com's) aren't in your VPC and can't use the endpoint, so an install that serves those still needs the internet route for whatever they call.

**2. How will your Git platform deliver webhooks?** Your Git platform calls the hub every time someone pushes, and it isn't on your runner network, so the endpoint you're about to create won't carry its traffic. This is the part most likely to have lead time, so settle it first: see [Decide the webhook path](#decide-the-webhook-path).

**3. How will developers reach the UI?** The UI is Grafana, on your install's apex hostname, and a private install resolves only inside the VPCs you connect to it. If your developers already work behind a VPN, Direct Connect, or Transit Gateway that reaches the VPC, PrivateLink covers them once you add DNS forwarding and a route ([step 5](#step-5-reach-the-install-from-elsewhere)). If they don't, you would have to build that path first, and an install on the internet, where Grafana sits behind a login, may be the better trade.

## How it works

Earthly publishes your install as an **endpoint service**. You create a matching **interface endpoint** in your VPC, plus two DNS records so your install's normal hostnames resolve to it.

```mermaid
flowchart LR
  subgraph Customer["Your AWS account"]
    CI["CI runners · CLI users<br/>browsers"]
    EP["Interface VPC endpoint<br/>(you create)"]
    CI --> EP
  end
  subgraph Dedicated["Lunar Dedicated account (Earthly-managed)"]
    ES["Endpoint service<br/>(Earthly creates)"]
    LB["Internal proxies"]
    HUB["Lunar Hub · Grafana"]
    ES --> LB --> HUB
  end
  EP -- "AWS PrivateLink (one-way, you initiate)" --> ES
```

Three properties are worth knowing up front, because they answer most security-review questions:

* **The connection is one-way.** Your side initiates every session. Nothing in the install can initiate a connection back into your VPC.
* **Neither side learns the other's addresses.** PrivateLink does not join the networks, add routes, or expose either CIDR range. Overlapping address ranges are fine.
* **You reach only the service Earthly publishes.** Not the VPC it lives in, not anything else in Earthly's account.

You own and pay for the endpoint, the hosted zone, and any resolver infrastructure in [step 5](#step-5-reach-the-install-from-elsewhere).

{% hint style="warning" %}
**PrivateLink is not authentication.** It controls which AWS account may *create* an endpoint; it does not identify individual workloads or requests. The hub API still requires its bearer token.

**The UI has no login on a private install.** Grafana is read-only to anyone who can reach the endpoint, so the endpoint's security group is what limits who sees it. Scope it to the systems and people you intend, and treat reaching the endpoint as equivalent to being able to read the UI.

Lunar Dedicated does not support mutual TLS today.
{% endhint %}

## Checklist

If you've consumed endpoint services before, this is all you need. The [Prefer Terraform?](#prefer-terraform) section has the resource definitions.

1. Decide the [webhook path](#decide-the-webhook-path). Most likely to have lead time, so start it first.
2. Interface endpoint against Earthly's service name, in subnets whose **zone IDs** Earthly supports.
3. **Private DNS disabled** on that endpoint. Leaving it on fails endpoint creation.
4. Endpoint security group: inbound TCP **443** from your systems.
5. Private hosted zone for your install's domain, associated with that VPC.
6. Two **alias A records** in it, apex and wildcard, both targeting the endpoint's **regional** DNS name.
7. [Confirm it works](#confirm-it-works) and tell Earthly when you're good to go.

## What you need from Earthly first

Earthly builds its side first, so you'll receive these before you start:

| Value                               | Example                                                   |
| ----------------------------------- | --------------------------------------------------------- |
| **Region**                          | `us-east-2`                                               |
| **Endpoint service name**           | `com.amazonaws.vpce.us-east-2.vpce-svc-0123456789abcdef0` |
| **Supported availability zone IDs** | `use2-az1`, `use2-az2`                                    |
| **Your install's domain**           | `you.dedicated.earthly.dev`                               |
| **Hub hostname**                    | `hub.you.dedicated.earthly.dev`                           |
| **Port**                            | TCP 443                                                   |

Lunar Dedicated supports same-region endpoints only, so build everything here in the install's region.

Earthly also allowlists your AWS account beforehand, so your endpoint connects without an approval step. If your security team would rather Earthly allowlist one specific IAM role, send its ARN. It must be **pathless**, because AWS rejects principal ARNs containing a path on endpoint services.

## Four names that look alike

Four names come up below. Three of them are `vpce` strings that are easy to mix up, and the fourth is what your CI actually calls.

| Name                           | Looks like                                                                                | Used for                                                            |
| ------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| **Endpoint service name**      | `com.amazonaws.vpce.us-east-2.vpce-svc-0123456789abcdef0`                                 | Earthly gives you this; it's the input when you create the endpoint |
| **Endpoint ID**                | `vpce-0123456789abcdef0`                                                                  | your endpoint, once created                                         |
| **Endpoint regional DNS name** | `vpce-0123456789abcdef0-a1b2c3d4.vpce-svc-0123456789abcdef0.us-east-2.vpce.amazonaws.com` | the target your DNS records alias to                                |
| **Your install's hostnames**   | `you.dedicated.earthly.dev`, `hub.you.dedicated.earthly.dev`                              | what your CI and browsers actually call                             |

## Decide the webhook path

Your Git platform delivers webhooks to the hub. Without them, pushes don't trigger anything and pull/merge-request status checks never post. Your Git platform isn't on your runner network, so **the endpoint you're about to create won't carry that traffic.**

Which case applies depends on where your platform runs:

* **A cloud-hosted Git platform** (github.com, gitlab.com, etc.) delivers from the public internet and can't use PrivateLink. Earthly can leave *just* the webhook listener on the internet while everything else stays private. Webhooks are authenticated by a signed secret shared with the platform.
* **A self-managed Git platform in your own network** can usually reach the hub through the same endpoint, or through a second one in whichever VPC it runs in. The steps are the same either way.
* **A managed single-tenant Git platform** runs in your vendor's account. Some vendors offer an outbound private connection to a customer-published endpoint service; if yours does, request it and send Earthly the principal it gives you. **This is usually a support ticket with a lead time you don't control**, so raise it as soon as you have the endpoint service name. It runs in parallel with everything else here.

## Step 1 — Create the interface endpoint

In your runner VPC: **VPC → Endpoints → Create endpoint → Other endpoint services**, paste Earthly's service name, verify it, then choose subnets.

**Subnets must be in the zone IDs Earthly listed.** AWS maps zone *names* like `us-east-2a` to different physical zones in different accounts, so a name means nothing across an account boundary:

```bash
aws ec2 describe-availability-zones \
  --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output table
```

Use two of the supported zones for redundancy; one is enough if that's all you have. If none of your subnets land in a supported zone, talk to Earthly before creating anything, because either side may need to add a subnet in a shared zone.

This constrains **placement only**: workloads anywhere in the VPC reach the endpoint across zones by ordinary VPC routing.

Attach a security group allowing inbound **TCP 443** from your runners.

## Step 2 — Turn private DNS off

Leave **Enable DNS name** unchecked (`private_dns_enabled = false` in Terraform).

Some tools default it on, and endpoint creation then fails with:

```
Private DNS can't be enabled because the service ... does not provide a private DNS name.
```

Enabling it asks AWS to resolve a hostname the *provider* has verified, and Earthly deliberately publishes none. Handling DNS on your side is what lets a single endpoint serve every hostname your install has today or gains later.

## Step 3 — Create the private hosted zone

Create a **private hosted zone** named after your install's domain (`you.dedicated.earthly.dev`), associated with the VPC holding the endpoint. That zone is dedicated to this install, so shadowing all of it is safe.

Add **two alias A records** with the same target. You need both, because a wildcard doesn't match its own apex:

| Record                        | Serves                     |
| ----------------------------- | -------------------------- |
| `you.dedicated.earthly.dev`   | the Grafana UI             |
| `*.you.dedicated.earthly.dev` | `hub.` and everything else |

**The alias target is your endpoint's regional DNS name**, the third row of [Four names that look alike](#four-names-that-look-alike). Find it on the endpoint's **Details** tab under **DNS names**, or:

```bash
aws ec2 describe-vpc-endpoints --vpc-endpoint-ids vpce-0123456789abcdef0 \
  --query 'VpcEndpoints[0].DnsEntries' --output table
```

{% hint style="warning" %}
**Two ways to get the target wrong.**

Pick the **regional** entry, not one of the per-zone variants ending `...-us-east-2a...`. A zonal target pins all your traffic to one availability zone.

And alias to the **endpoint**, not to the hostname you're creating the record for, and not to Earthly's endpoint *service* name.
{% endhint %}

Use alias records rather than CNAMEs, because a CNAME isn't valid at a zone apex.

## Step 4 — Point your CI at the hub

Nothing about Lunar's configuration changes for a private install:

```bash
LUNAR_HUB_HOST=hub.you.dedicated.earthly.dev
LUNAR_HUB_TOKEN=...
```

The gRPC API and the HTTP endpoints share port 443, so only one port ever needs to be open. Authentication is the same bearer token you'd use over the internet.

## Step 5 — Reach the install from elsewhere

**Another VPC**: repeat steps 1 to 3 in it. Each VPC gets its own endpoint and its own copy of the two records; the hostnames stay the same everywhere.

**Laptops on the corporate network** need two separate things, and it's easy to do only the first:

1. **DNS**: forward `you.dedicated.earthly.dev` into the VPC, typically via a Route 53 Resolver inbound endpoint plus a conditional forwarder on your corporate resolvers.
2. **Network**: a route from the corporate network to the endpoint's addresses (VPN, Direct Connect, or Transit Gateway), and the endpoint's security group must allow that source.

Resolver forwarding only makes the name resolve. Do the first without the second and the browser gets the right address with no way to reach it.

## Prefer Terraform?

Steps 1 to 3 are five resources:

```hcl
resource "aws_security_group" "lunar_hub" {
  name   = "lunar-hub-endpoint"
  vpc_id = var.vpc_id
}

resource "aws_vpc_security_group_ingress_rule" "lunar_hub_tls" {
  security_group_id = aws_security_group.lunar_hub.id
  ip_protocol       = "tcp"
  from_port         = 443
  to_port           = 443
  cidr_ipv4         = var.runner_cidr # or referenced_security_group_id
}

resource "aws_vpc_endpoint" "lunar_hub" {
  vpc_id             = var.vpc_id
  vpc_endpoint_type  = "Interface"
  service_name       = "com.amazonaws.vpce.us-east-2.vpce-svc-0123456789abcdef0"
  subnet_ids         = var.subnet_ids # subnets in the zone IDs Earthly supports
  security_group_ids = [aws_security_group.lunar_hub.id]

  # Required: Earthly publishes no verified private DNS name. See step 2.
  private_dns_enabled = false
}

resource "aws_route53_zone" "lunar" {
  name = "you.dedicated.earthly.dev"

  vpc {
    vpc_id = var.vpc_id
  }
}

resource "aws_route53_record" "lunar" {
  for_each = toset(["you.dedicated.earthly.dev", "*.you.dedicated.earthly.dev"])

  zone_id = aws_route53_zone.lunar.zone_id
  name    = each.value
  type    = "A"

  alias {
    name                   = aws_vpc_endpoint.lunar_hub.dns_entry[0].dns_name
    zone_id                = aws_vpc_endpoint.lunar_hub.dns_entry[0].hosted_zone_id
    evaluate_target_health = false
  }
}
```

{% hint style="warning" %}
**AWS doesn't document the order of `dns_entry`,** so `[0]` being the regional entry is a convention rather than a guarantee. Output it and check the value has no availability zone in it before relying on this in production.
{% endhint %}

## Confirm it works

From a host inside the VPC:

```bash
getent hosts hub.you.dedicated.earthly.dev
curl -sS https://you.dedicated.earthly.dev/api/health
```

The first should return the endpoint's **private** addresses; public addresses mean your hosted zone isn't answering and traffic would be leaving your network. The second should return JSON.

{% hint style="info" %}
**An endpoint reaching `available` proves less than it sounds.** It means the PrivateLink connection was established, not that DNS is right and not that anything behind it works. The two commands above are the real check.
{% endhint %}

Then tell Earthly three things, so the install can be verified end to end from both sides:

* **Your endpoint ID**, so Earthly can confirm the connection is accepted and healthy rather than pending or rejected.
* **Which zone IDs you placed it in**, which is what Earthly checks against if connectivity works from some subnets but not others.
* **Which webhook case applies**, plus any principal to allowlist for a vendor-managed connection.

## Troubleshooting

**Endpoint creation fails on private DNS.** `Private DNS can't be enabled because the service ... does not provide a private DNS name` means the flag is on. See [step 2](#step-2-turn-private-dns-off).

**Endpoint creation fails on availability zones.** Your subnets aren't in a zone the service supports. Compare zone **IDs**, not names.

**Endpoint creation is rejected outright.** Your account isn't allowlisted, or a different one is. An unlisted account can't see the service at all, so the failure never reaches Earthly's side. Confirm the account ID.

**The endpoint sits in `pendingAcceptance`.** Earthly's service accepts allowlisted connections automatically, so tell Earthly rather than waiting.

**Hostnames don't resolve, or resolve to public addresses.** The hosted zone isn't associated with the VPC you're testing from, or the records are missing. Test from inside that VPC; laptops need [step 5](#step-5-reach-the-install-from-elsewhere).

**Hostnames resolve but connections hang.** Check the endpoint's security group allows TCP 443 from the client.

**Only the apex works, or only `hub.` works.** Only one of the two records exists, and both are needed.

**Traffic works from one availability zone only.** The alias targets a zonal endpoint DNS name rather than the regional one. See [step 3](#step-3-create-the-private-hosted-zone).

**A deeper hostname resolves but fails TLS.** DNS wildcards match at any depth, so `a.b.you.dedicated.earthly.dev` resolves via your wildcard record, but the install's certificate only covers one level. Lunar only ever uses single-label names like `hub.`, so this only appears if something is calling a name Lunar doesn't publish.

**Everything works except webhooks.** Your Git platform reaches the hub by a different path from your runners. See [Decide the webhook path](#decide-the-webhook-path).


# PrivateLink to Your Systems

Give your Lunar Dedicated install a private path to internal systems (an internal Git server, registry, or API) by publishing them as an AWS PrivateLink endpoint service.

A Lunar Dedicated install reaches outwards to the systems it works with. It calls your **Git platform** to read repositories and post results, and its collectors, catalogers, and policies reach whatever they gather data from, such as registries, ticketing systems, and internal APIs. When one of those isn't reachable from the public internet, Lunar needs a private path to it.

This page is the setup guide for that path. It's written for whoever runs your AWS networking. You can forward it on its own.

{% hint style="info" %}
This page covers Lunar reaching **out** to your systems. Reaching the Lunar hub **in** from your CI and browsers is a separate, independent setup, covered in [PrivateLink to Your Hub](/install/lunar-hub/dedicated/privatelink-inbound), which is the recommended way to do it. See the [Overview](/install/lunar-hub/dedicated/overview#how-you-connect) for the alternatives.
{% endhint %}

{% hint style="warning" %}
**A self-managed Git platform needs both directions.** If your GitHub Enterprise Server or GitLab instance only resolves inside your network, it is a target for this page: Lunar has to reach it to read repositories and post results. Separately, your instance has to deliver **webhooks** to the hub, which this page does not cover. If it can reach the internet, it delivers to your install's public webhook listener; if it cannot, it needs a private path in — see [Decide the webhook path](/install/lunar-hub/dedicated/privatelink-inbound#decide-the-webhook-path).
{% endhint %}

## Do you need this?

Each of these can rule the work out, so answer them before you build anything.

**1. Is the target actually unreachable from the internet?** Your Lunar install has a single, stable outbound IP address. If your service is internet-facing and you can allowlist one IP, that's far less work than everything below. Ask Earthly for the address.

**2. Can you publish TXT records on a publicly-resolvable domain?** AWS requires proof of domain ownership before it will attach a hostname to an endpoint service. Verification is scoped to the *parent* domain, so a name under `example.com` works for any subdomain. If your internal hostname sits under a suffix you can't prove ownership of (something like `.corp` or `.internal` with no public zone), tell Earthly, because there's a fallback for that case (Earthly overrides DNS inside the install instead) and it changes what you build here.

**3. Can the service present a publicly-trusted TLS certificate?** Lunar connects with hostname verification on, so the certificate must be valid for the hostname in question 2 *and* trusted by Lunar's workloads.

If you control the public zone from question 2, you can almost certainly get a public certificate for this name: DNS-01 validation only needs a TXT record, so the name never has to resolve publicly. Terminating TLS at the load balancer with an ACM certificate works the same way.

If the service can only present a certificate from your **internal** CA, tell Earthly. We'll need your CA's root and intermediate **certificates** (the public ones, never a private key) to add to the trust store Lunar's workloads use. Flag it too if your CA's revocation endpoints (CRL or OCSP) are only reachable inside your network, since Lunar won't be able to reach them.

**4. How many distinct hostnames does Lunar need to reach?** Not systems; **hostnames**. Remember to count your Git platform if it's self-managed and internal. An endpoint service can carry exactly one private DNS name, though that name may be a wildcard (`*.internal.example.com`). Three hostnames means three endpoint services, a gateway doing path-based routing under a single hostname, or a wildcard name in front of a gateway that routes by hostname.

## How it works

You publish your internal service as an **endpoint service**. Earthly creates a matching **interface endpoint** inside the Lunar install's VPC. Lunar then reaches your service by its normal hostname, over a private AWS path.

```mermaid
flowchart LR
  subgraph Dedicated["Lunar Dedicated account (Earthly-managed)"]
    W["Lunar Hub · collector<br/>and policy workloads"]
    EP["Interface VPC endpoint<br/>(Earthly creates)"]
    W --> EP
  end
  subgraph Customer["Your AWS account"]
    ES["Endpoint service<br/>(you create)"]
    NLB["Internal Network Load Balancer<br/>(you create)"]
    SVC["Your internal service"]
    ES --> NLB --> SVC
  end
  EP -- "AWS PrivateLink — one-way, Lunar initiates" --> ES
```

Three properties are worth knowing up front, because they answer most security-review questions:

* **The connection is one-way.** Lunar initiates every session. Nothing in your account can initiate a connection back through it.
* **Neither side learns the other's addresses.** PrivateLink does not join the networks, add routes, or expose either CIDR range. Overlapping address ranges are fine.
* **Lunar can reach only the service you publish.** Not the VPC it lives in, not anything else in your account.

{% hint style="warning" %}
**PrivateLink is not authentication.** It's a private network path, nothing more. TLS and your service's normal credentials remain fully in force, exactly as if Lunar were calling over the internet.
{% endhint %}

## Checklist

If you've published endpoint services before, this is all you need. The [Prefer Terraform?](#prefer-terraform) section has the resource definition.

1. Internal NLB in front of the service, targets healthy.
2. Endpoint service backed by that NLB.
3. Allowed principal: `arn:aws:iam::<DEDICATED_ACCOUNT_ID>:root` (Earthly provides the ID).
4. Acceptance: **automatic**. See [step 4](#step-4-choose-automatic-or-manual-acceptance) before choosing manual; it requires extra steps.
5. Private DNS name: your service's real TLS hostname. Publish the TXT record, wait for `verified`.
6. Send Earthly the values in [What to send back](#step-6-what-to-send-back).

Three details that are easy to miss:

* **Each endpoint service gets its own TXT verification name and value**, so an already-verified domain does not carry over from another service.
* The private DNS name must be the hostname on your service's TLS certificate, or Lunar's requests will fail hostname verification. If that certificate comes from an internal CA rather than a public one, Earthly needs your CA certificates too.
* **Availability zones must be exchanged as zone IDs, not zone names** (see [step 6](#step-6-what-to-send-back)).

## What you need from Earthly first

You need two values from Earthly. The **region** is fixed per install and agreed up front; whoever forwarded you this page will have it. Everything you build here must be in the same region as the install.

The other is the **dedicated account ID**, used for the allowed principal in [step 3](#step-3-allow-earthlys-dedicated-account). It's needed at just that one step, so if you don't have it yet, start building anyway and slot it in when it arrives.

## Step 1 — Put the service behind an internal Network Load Balancer

An endpoint service can only be backed by a Network Load Balancer, so the service needs one in front of it.

* Scheme: **internal**. PrivateLink reaches an internal NLB fine, and an internet-facing one would expose the service publicly, defeating the point.
* Add a TCP listener on the port Lunar will use: **443** unless you tell Earthly otherwise.
* Register your service as a target and **confirm the targets are healthy** before continuing.

An unhealthy target group produces a connection that looks fine on both sides and still refuses traffic. The endpoint will happily reach `Available` with a completely broken backend.

Note the availability zones your NLB is in, as **zone IDs** (`euw2-az1`) rather than zone names (`eu-west-2a`), because names don't mean the same thing in Earthly's account. Endpoint services are only reachable in the zones their load balancer covers, and Earthly's endpoint has to land in a zone you support. You'll send these in [step 6](#step-6-what-to-send-back).

## Step 2 — Create the endpoint service

In the AWS console, go to **VPC → Endpoint services → Create endpoint service**. Not the load balancer screens; this is its own top-level resource.

* Load balancer type: **Network**
* Select the NLB from step 1
* **Require acceptance for endpoint**: the console asks now; automatic acceptance (box unchecked) is recommended

{% hint style="warning" %}
Requiring manual acceptance delays the connection and forces a two-step setup on Earthly's side, so read [step 4](#step-4-choose-automatic-or-manual-acceptance) before checking that box.
{% endhint %}

AWS generates a service name that looks like:

```
com.amazonaws.vpce.<region>.vpce-svc-0123456789abcdef0
```

That string is the value Earthly actually uses to connect. Keep it.

## Step 3 — Allow Earthly's dedicated account

On the endpoint service, open **Allow principals** and add:

```
arn:aws:iam::<DEDICATED_ACCOUNT_ID>:root
```

This permits principals in that one account to *request* a connection to this one service. It grants no IAM credentials, no assumable role, and no access to anything else in your account. A request from any other account is rejected by AWS before it ever appears in your console.

{% hint style="warning" %}
**You may see a second, similar-looking ARN during Lunar onboarding.** Setting up the install also involves an IAM role that *your* side assumes to deposit secrets. That role trusts your account and uses role assumption with an ExternalId (covered in [Setup](/install/lunar-hub/dedicated/setup#step-2-identify-your-depositor-account)). This allowlist entry is not that: it involves no role assumption at all, only permission to request a PrivateLink connection.
{% endhint %}

## Step 4 — Choose automatic or manual acceptance

**Automatic acceptance is strongly recommended.** Earthly's endpoint connects as soon as it's created, and private DNS is configured in the same step. Your access control is the allowlist from step 3, which you've already set; automatic acceptance widens nothing.

**Manual acceptance** adds a human approval for each connection. Someone on your side approves it at **VPC → Endpoint services → your service → Endpoint connections**, selects the endpoint ID Earthly sends, and chooses **Accept endpoint connection request**. The CLI equivalent is `aws ec2 accept-vpc-endpoint-connections`. It's a single approval, with nothing to configure.

{% hint style="warning" %}
**Manual acceptance costs more than one click.** AWS will not enable private DNS on an endpoint whose connection hasn't been accepted yet. So setup on Earthly's side happens in two steps: create the endpoint, wait for your approval, then make a second change to turn on private DNS. Until that second change lands, your hostname doesn't resolve inside the install.

Choose manual only if your process genuinely requires a named approver per connection. If it does, tell Earthly at [step 6](#step-6-what-to-send-back) so the second step is planned rather than discovered.
{% endhint %}

## Step 5 — Associate and verify the private DNS name

This step is what lets Lunar call your service by its real hostname, with working TLS. Skipping it means Lunar can only reach an AWS-generated hostname that your certificate won't match.

**5a. Associate the name.** On the endpoint service, set **private DNS name** to your service's canonical hostname: the name on its TLS certificate, for example `service.internal.example.com`.

Use a fully-qualified name. A bare single-label name (just `gitlab`) will not work.

**5b. Publish the verification record.** AWS returns a TXT record name and value. Find them under **Domain verification name** and **Domain verification value** on the service's **Details** tab, or:

```bash
aws ec2 describe-vpc-endpoint-service-configurations \
  --service-ids vpce-svc-0123456789abcdef0 \
  --query 'ServiceConfigurations[*].PrivateDnsNameConfiguration'
```

Create that TXT record in the **public** DNS zone for the domain:

| Name                            | Type | Value                   |
| ------------------------------- | ---- | ----------------------- |
| `_a1b2c3d4e5f6g7h8.example.com` | TXT  | `vpce:AbCdEf0123456789` |

{% hint style="info" %}
**An already-verified domain does not carry over.** Each endpoint service gets its own verification name and value, so if you've published private DNS names for other services on this domain you still need a new TXT record for this one.
{% endhint %}

**5c. Wait for verification.** Check **Domain verification status** on the Details tab. If it stays pending, use **Actions → Verify domain ownership for private DNS name** to retry. DNS changes can take up to 48 hours to propagate, though it's usually much faster.

**Do not tell Earthly you're ready until the status reads `verified`.** Earthly's endpoint is created with private DNS enabled, and that request is rejected against a service whose name isn't verified yet.

## Prefer Terraform?

Steps 2–5 are one resource. This is the recommended minimal shape if your networking is Terraform-managed:

```hcl
resource "aws_vpc_endpoint_service" "lunar" {
  acceptance_required        = false
  network_load_balancer_arns = [aws_lb.internal.arn]
  private_dns_name           = "service.internal.example.com"

  allowed_principals = [
    "arn:aws:iam::<DEDICATED_ACCOUNT_ID>:root",
  ]
}
```

The TXT record from step 5 is still published separately, through your DNS provider or an `aws_route53_record` if the zone lives in Route53.

## Step 6 — What to send back

Send Earthly these six values:

| Value                               | Example                                                   |
| ----------------------------------- | --------------------------------------------------------- |
| **Endpoint service name**           | `com.amazonaws.vpce.eu-west-2.vpce-svc-0123456789abcdef0` |
| **Verified private DNS name**       | `service.internal.example.com`                            |
| **TCP port**                        | `443`                                                     |
| **Region**                          | `eu-west-2`                                               |
| **Acceptance**                      | automatic, or manual                                      |
| **Supported availability zone IDs** | `euw2-az1`, `euw2-az2`                                    |

If you flagged an internal CA in question 3, include your CA certificates as well. Earthly needs nothing further to create its side of the connection.

{% hint style="warning" %}
**Send zone IDs, not zone names.** AWS maps zone *names* like `eu-west-2a` to different physical zones in different accounts, so a name is meaningless across an account boundary. Zone **IDs** like `euw2-az1` are stable everywhere. Get them with:

```bash
aws ec2 describe-availability-zones \
  --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output table
```

Send the IDs for the zones your load balancer covers.
{% endhint %}

## What happens next

From your six values, Earthly creates the interface endpoint, plus a security group that allows only the port you specified, and only from Lunar's workloads.

If you chose manual acceptance, Earthly sends you the endpoint ID at this point and waits for your approval, then makes a second change to enable private DNS.

An endpoint reaching `Available` proves less than it sounds: it means the PrivateLink connection was established, not that anything behind it works. So Earthly checks two things from a real Lunar workload:

* **The hostname resolves to the endpoint's private addresses.** This is what proves traffic is taking the private path, rather than resolving to a public address and quietly bypassing the endpoint.
* **TLS completes with hostname verification enabled.** A successful handshake exercises the whole path, so it also confirms your load balancer targets are healthy and your certificate covers the name.

Earthly then runs a collection against your service and confirms the data lands. It's the same end-to-end check every install gets, private or not.

## How DNS resolves

Worth understanding if you're reviewing this, because nothing about it is visible in your account.

When Earthly creates the interface endpoint, AWS reads the verified private DNS name from your endpoint service and creates a hidden, AWS-managed private hosted zone associated with the Lunar VPC. That zone maps your hostname to the endpoint's network interfaces.

The consequence is split-horizon resolution: inside the Lunar install, `service.internal.example.com` resolves to the private endpoint. Everywhere else in the world, it resolves however it always did. Lunar's configuration contains an ordinary hostname and an ordinary HTTPS URL; nothing in Lunar knows PrivateLink is involved.

{% hint style="warning" %}
**Don't remove the TXT record afterwards.** If domain verification later lapses, AWS denies *new* connection requests while existing connections keep working. The install carries on fine and the problem surfaces only when the endpoint is next recreated, potentially months later during an unrelated maintenance window.
{% endhint %}

## Troubleshooting

**Earthly's endpoint is stuck in `PendingAcceptance`.** Manual acceptance is enabled and nobody has approved the connection. Approve it under **Endpoint connections** on your service.

**Earthly reports the connection request failed outright.** Usually, the allowed principal is incorrect. Check the account ID in step 3 matches the one Earthly gave you exactly. An unlisted account can't see the service at all, so the failure happens on Earthly's side and never reaches your console.

**Endpoint creation fails with a private DNS error.** An error saying private DNS requires an accepted connection means manual acceptance is on. Approve the pending connection; Earthly completes the second step. See [step 4](#step-4-choose-automatic-or-manual-acceptance).

**Endpoint creation fails on availability zones.** Earthly's endpoint has to be placed in zones your service supports. Send the zone **IDs** from step 6 (zone names don't translate across accounts) and Earthly will constrain placement.

**The endpoint is `Available` but requests fail or hang.** Check your NLB target group health first. Endpoint state reflects the PrivateLink connection, not your backend.

**Requests connect but TLS fails.** The hostname Lunar is calling isn't covered by your certificate's subject alternative names. The private DNS name in step 5 must be a name the certificate actually presents.

**Domain verification won't complete.** Check whether your DNS provider allows underscores in record names; if not, you can omit the AWS-supplied prefix and use the bare domain. Also check that your provider hasn't appended the domain a second time, or lowercased the value; AWS matches it exactly.


# Git Platforms

What Lunar supports on GitHub and GitLab — identity, webhooks, policy results, merge gating, and CI — and where the setup instructions for each Git platform live.

Lunar integrates with **GitHub** (github.com and GitHub Enterprise Server) and **GitLab** (gitlab.com and self-managed instances). One Hub can serve both at once, and each repository is routed by its host.

Most of Lunar works identically on either Git platform: components, domains, collectors, policies, catalogers, the SQL API, and the Grafana dashboards behave the same on both. The differences are concentrated in a few places, and this page is the summary — read it before planning an install so nothing on the list below is a surprise later.

Creating the credential is platform-specific, and lives on each platform's page:

* [**GitHub**](/install/git-platforms/github) — create the GitHub App the Hub authenticates as.
* [**GitLab**](/install/git-platforms/gitlab) — create the service account and its token, and set up the merge gate on Ultimate.

Everything after that is the ordinary install: [Self hosted](/install/lunar-hub/self-hosted) or [Dedicated](/install/lunar-hub/dedicated), each of which calls out the GitHub and GitLab variants inline where they diverge.

## Capability summary

| Capability                     | GitHub                                                                                            | GitLab                                                                                                                                             |
| ------------------------------ | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Repository identity            | `github.com/org/repo`                                                                             | `gitlab.com/group/project`, including nested subgroups                                                                                             |
| Credentials                    | [GitHub App](/install/git-platforms/github)                                                       | [Service account token](/install/git-platforms/gitlab)                                                                                             |
| Webhook ingestion              | Push, pull request, workflow run, PR comments                                                     | Push, merge request, MR comments                                                                                                                   |
| Webhook registration           | Automatic, per repository                                                                         | Automatic, per project                                                                                                                             |
| Results on pull/merge requests | Check run + PR comment                                                                            | Commit status (GitLab Free/Premium) or external status check (GitLab Ultimate) + MR comment                                                        |
| Results on the default branch  | Check run + Grafana dashboards                                                                    | Commit status + Grafana dashboards; project badges                                                                                                 |
| Merge gating                   | [Mark Lunar's check required in branch protection](/install/git-platforms/github#blocking-merges) | [Provisioned automatically on Ultimate](/install/git-platforms/gitlab#merge-gate); on Free and Premium, enable **Pipelines must succeed** yourself |
| Comment-driven bypass          | [`/lunar bypass:` on the PR](/docs/pr-comments)                                                   | [`/lunar bypass:` on the MR](/docs/pr-comments), on every tier                                                                                     |
| Config sync                    | [GitHub Action](/install/lunar-hub/self-hosted/sync-config#github-actions) or CLI                 | [`.gitlab-ci.yml` job](/install/lunar-hub/self-hosted/sync-config#gitlab-ci) running the CLI                                                       |

Which CI systems Lunar traces is a separate question from which Git platform you are on — see [Lunar CI Tracer](/install/ci-tracer).

## What the differences mean in practice

### Merge gating depends on your GitLab tier

On GitHub, Lunar posts a check run and you decide whether to make it required in branch protection. See [blocking merges](/install/git-platforms/github#blocking-merges). A blocked pull request can be overridden with a [`/lunar bypass:` comment](/docs/pr-comments).

On GitLab **Ultimate**, Lunar blocks merge requests whose gating policies do not pass, and provisions the necessary project settings itself — see the [merge gate](/install/git-platforms/gitlab#merge-gate) for what it changes and how to override a block.

On GitLab **Free** and **Premium**, Lunar posts a commit status instead. That can still gate merges, but you turn it on per project with **Pipelines must succeed**, and it gates on the whole pipeline rather than on Lunar as a named requirement. The [`/lunar bypass:` comment](/docs/pr-comments) works here too, against the commit status that setting reads.

### Self-managed hosts must be configured

GitHub Enterprise Server and self-managed GitLab are both supported, but a self-managed GitLab host is only recognized as GitLab if you list it in your credentials configuration. See [GitLab](/install/git-platforms/gitlab).

## Running GitHub and GitLab together

One Hub can serve both. Configure the GitHub App as usual and add `HUB_GITLAB_TOKENS` alongside it; each repository is routed by its host. Neither platform is mandatory to the Hub, which starts as long as at least one is configured.

The Helm chart has not caught up on that last point: its template guard still requires the GitHub App values, so a self-hosted chart install carries a GitHub App configuration even when every component lives on GitLab. See [install Step 4](/install/lunar-hub/self-hosted/install-walkthrough#git-platform-credentials); dedicated `hub.gitlab.*` values are planned.

## Next steps

* [GitHub](/install/git-platforms/github) — GitHub App setup.
* [GitLab](/install/git-platforms/gitlab) — token setup, Hub configuration, and the merge gate.

Then carry the credential into your install:

* [Self hosted](/install/lunar-hub/self-hosted) — [Prerequisites](/install/lunar-hub/self-hosted/prerequisites) and the [install walkthrough](/install/lunar-hub/self-hosted/install-walkthrough).
* [Dedicated](/install/lunar-hub/dedicated) — Earthly configures the Hub; you [deposit the credential](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets) into your install.


# GitHub

Set up GitHub for Lunar. Create the GitHub App the Hub authenticates as, review the permissions it holds, serve several organizations from one Hub, and block merges on Lunar's check run.

The Hub authenticates with a **GitHub App** installed on your organization. The App vends short-lived installation tokens, which Lunar uses to read repositories and post results, so no long-lived credential is stored anywhere.

You create the App; Lunar never creates one on your behalf. Either flow below produces the same App.

This page covers the GitHub side only. The install steps that consume it are in [Prerequisites → Step 5](/install/lunar-hub/self-hosted/prerequisites#step-5-connect-your-git-platform) for self-hosted, or the [Dedicated](/install/lunar-hub/dedicated/setup) setup steps.

## Permissions

The App ends up with these permissions however you create it: the setup tool sets them for you, and the [manual flow](#manual-setup-alternative) has you set them by hand. This is the list to hand a security reviewer.

| Permission           | Access | Why                                                                                                                     |
| -------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- |
| `actions`            | read   | Read workflow runs for CI data collection                                                                               |
| `checks`             | write  | Post policy results as PR checks                                                                                        |
| `contents`           | read   | Fetch config and source for policy evaluation                                                                           |
| `metadata`           | read   | Required by GitHub on every App; also covers the role check behind the [bypass comment](/docs/pr-comments#lunar-bypass) |
| `pull_requests`      | write  | Post PR comments and statuses                                                                                           |
| `repository_hooks`   | write  | Auto-register per-repo webhooks                                                                                         |
| `organization_hooks` | write  | Auto-register organization-level webhooks                                                                               |

**Events:** `push`, `pull_request`, `workflow_run`, `issue_comment`.

## App creation

### Setup tool (recommended)

1. Visit [**earthly.dev/lunar/github-app-setup**](https://earthly.dev/lunar/github-app-setup/).
2. Follow the prompts. The tool uses GitHub's [manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) to register the App with the permissions and events above.
3. **Download the PEM private key when prompted.** GitHub shows it exactly once — if you click past this page, you'll have to generate a new key from the App settings later.
4. Click *Install App on GitHub* and select the org. Choose **All repositories** unless you have a specific reason not to — Lunar's actual monitoring scope is configured in `lunar-config.yml`, so a narrower scope here just means coming back to **Org Settings → GitHub Apps → Lunar → Repository access** every time you add a new repo to Lunar.

The hosted tool proxies the manifest exchange to GitHub and returns the credentials to your browser; we never persist them.

### Manual setup (alternative)

{% hint style="info" %}
**Create the App by hand** when the hosted tool can't reach your environment — either of these:

* **GitHub Enterprise Server.** The App has to be created on your own GHES instance.
* **Air-gapped**, or `earthly.dev` is otherwise unreachable from the browser session you would run the tool in.
  {% endhint %}

Create the App at GitHub's [App creation page](https://github.com/settings/apps/new) (or for an org: **Org Settings → Developer settings → GitHub Apps → New GitHub App**). For GHES, create it on your GHES instance instead. GitHub's [registering-a-github-app docs](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) walk through every form field if you want a reference.

Set the [permissions and events above](#permissions), then fill in the rest:

* **Homepage URL** — any URL works (e.g. your internal Lunar URL, or `https://earthly.dev/lunar`).
* **Webhook** — uncheck "Active." The Hub registers its own per-repo webhooks at runtime; the App-level webhook is unused. URL can be a placeholder (e.g. `https://example.com/placeholder`) — GitHub requires a value but nothing will ever hit it.
* **Webhook secret** — leave blank. Since you unchecked "Active" above, GitHub won't deliver App-level events. The Hub's per-repo webhook signing secret is a separate thing — see [prereqs Step 6](/install/lunar-hub/self-hosted/prerequisites#step-6-plan-your-kubernetes-secrets).
* **Where can this app be installed?** — "Only on this account."

Then, once the App exists:

1. **Generate a private key** (App settings → "Private keys" → "Generate a private key"). Save the `.pem` file — GitHub does not show it again.
2. **Install the App** on your organization (App settings → "Install App"). Choose **All repositories** unless you have a specific reason not to, for the same reason as above.

Whichever flow you used, you now hold four things: the owner, the App ID, the installation ID, and the PEM private key. [Prereqs Step 5](/install/lunar-hub/self-hosted/prerequisites#step-5-connect-your-git-platform) lists where each one comes from. Confirm you have all four, and that the PEM is saved, before moving on.

## Hub configuration (Dedicated)

Nothing to configure: Earthly runs the Hub and configures it for you. Continue at [Dedicated → Deposit your credentials](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets), where the PEM goes to your install rather than to Earthly. The self-hosted section below does not apply to you.

## Hub configuration (self-hosted)

Those four values are what the Hub reads. [Install Step 4](/install/lunar-hub/self-hosted/install-walkthrough#git-platform-credentials) has the values block to paste; this is the reference behind it.

| Setting         | Chart value                     | Environment variable         | Meaning                                                                                                                                      |
| --------------- | ------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Owner           | `hub.github.app.owner`          | `HUB_GITHUB_APP_OWNER`       | The org or user the App is installed on. Matching is case-insensitive and trimmed.                                                           |
| App ID          | `hub.github.app.id`             | `HUB_GITHUB_APP_ID`          | Numeric. Quote it in YAML, or it renders as scientific notation.                                                                             |
| Installation ID | `hub.github.app.installId`      | `HUB_GITHUB_APP_INSTALL_ID`  | Numeric, from the install URL.                                                                                                               |
| Private key     | mounted from `lunar-github-app` | `HUB_GITHUB_APP_PRIVATE_KEY` | The base64-encoded PEM.                                                                                                                      |
| Base URL        | `hub.github.baseUrl`            | `HUB_GITHUB_BASE_URL`        | GHES only. Your instance's API endpoint.                                                                                                     |
| Host            | (none)                          | `HUB_GITHUB_HOST`            | Defaults to `github.com`. On GHES set it to your instance hostname, so component names (`<host>/org/repo`) match what your manifests author. |

All four App fields are required together. A partial configuration is rejected at startup.

### Multiple organizations

When one Hub fronts several orgs that each install their own Lunar App, register one entry per owner in `HUB_GITHUB_APPS` instead of the single-App variables:

```bash
export HUB_GITHUB_APPS=$(cat <<'EOF'
[
  {"owner": "earthly", "app_id": 123, "private_key_path": "/secrets/github-apps/earthly.pem", "install_id": 100},
  {"owner": "acme",    "app_id": 456, "private_key_path": "/secrets/github-apps/acme.pem",    "install_id": 200}
]
EOF
)
```

Each entry pins its own App credentials and installation; the Hub mints tokens scoped to the right org per request. Owner matching is case-insensitive and trimmed.

For the PEM files, the recommended pattern is one Kubernetes Secret with multiple keys, mounted as a single volume:

```yaml
# Secret
data:
  earthly.pem: <base64-encoded PEM>
  acme.pem:    <base64-encoded PEM>

# Pod
volumes:
- name: github-apps
  secret: { secretName: github-apps }
volumeMounts:
- { name: github-apps, mountPath: /secrets/github-apps, readOnly: true }
```

`HUB_GITHUB_APPS` is mutually exclusive with the single-App env vars (`HUB_GITHUB_APP_OWNER` / `HUB_GITHUB_APP_ID` / `HUB_GITHUB_APP_PRIVATE_KEY` / `HUB_GITHUB_APP_INSTALL_ID`). Use one mode or the other.

### Avoid GitHub rate limiting

GitHub gives every App installation its own REST rate-limit budget, so you can mitigate GitHub rate limits by installing additional Apps. A large organization may need 2–10 Apps to stay within the rate limit.

Create each additional App the same way as the first, through either flow in [App creation](#app-creation).

Register each as another entry in `HUB_GITHUB_APPS` with the same owner, its own `app_id` and `install_id`, and its own PEM:

```bash
export HUB_GITHUB_APPS=$(cat <<'EOF'
[
  {"owner": "earthly", "app_id": 123, "private_key_path": "/secrets/github-apps/earthly.pem",   "install_id": 100},
  {"owner": "earthly", "app_id": 789, "private_key_path": "/secrets/github-apps/earthly-2.pem", "install_id": 300}
]
EOF
)
```

Give each entry a distinct `private_key_path` — the two Apps have different private keys, and pointing both at one file makes the second fail to authenticate.

Lunar Hub spreads **read** traffic evenly across every App registered for an owner. Writes — commit statuses and pull-request comments — always stay on the first App listed for that owner, because GitHub only allows the App that created a check run or comment to update it.

On Kubernetes, set this through `hub.github.apps` and give the second entry a `privateKeyFile` so it reads its own key from the App Secret (chart 3.14.0+):

```yaml
hub:
  github:
    apps:
      - owner: earthly
        appId: 123
        installId: 100
      - owner: earthly
        appId: 789
        installId: 300
        privateKeyFile: earthly-2.pem
```

## Blocking merges

Lunar reports each evaluation as an **`Earthly Lunar` check run** on the commit. On its own the check is informational; it blocks merges when you require it in branch protection: **Settings → Branches → Branch protection rules → Require status checks to pass**, and add `Earthly Lunar` to the required checks. Unlike [GitLab Ultimate's merge gate](/install/git-platforms/gitlab#merge-gate), Lunar does not provision this setting for you. Whether the check is required stays your call, per branch rule.

The check run appears once Lunar has policy results for the commit. While a required check is waiting on data, it remains in progress and reads `N of M required checks pending`.

Only policies at a gating enforcement level participate. See [policies](/configuration/lunar-config/policies).

### Overriding a block

When a pull request is blocked and needs to merge anyway, an authorized engineer comments `/lunar bypass: <reason>` on it; `/lunar bypass rm` takes the override back. The [PR/MR comments reference](/docs/pr-comments) documents both commands and the `maintain`-or-`admin` bar they require. A [`lunar policy bypass-pr`](/docs/lunar-cli#lunar-policy-bypass-pr) from the CLI also clears the check run on its next evaluation.

A check run that went green on a bypass says so in its title, reading `3 required check(s) bypassed` rather than reporting a pass, so a reviewer scanning the checks list can tell the two apart. The comment below it names who authorized each one.

## Next steps

Self-hosted installs carry these four values into Kubernetes secrets at [prereqs Step 6](/install/lunar-hub/self-hosted/prerequisites#step-6-plan-your-kubernetes-secrets), and into your chart values at [install Step 4](/install/lunar-hub/self-hosted/install-walkthrough#git-platform-credentials). On Dedicated you [deposit the PEM](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets) into your install's secret drop instead.

For how GitHub and GitLab differ once Lunar is running, see [Git Platforms](/install/git-platforms).


# GitLab

Set up GitLab for Lunar. Create the service account and its token, configure the Hub, and understand how results are reported and how merge blocking works per tier.

The Hub authenticates as a **service account**: a machine user that you create, grant group memberships to, and issue a token for.

This page covers the GitLab side only. The install steps that consume it are in [Prerequisites → Step 5](/install/lunar-hub/self-hosted/prerequisites#step-5-connect-your-git-platform) for self-hosted, or the [Dedicated](/install/lunar-hub/dedicated/setup) setup steps.

## Authentication

How many accounts and tokens you need depends on where your GitLab runs:

| Your GitLab                      | Service account                                                                            | Tokens                       |
| -------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------- |
| GitLab Dedicated or self-managed | A single **instance service account**, made a member of every top-level group Lunar serves | **One token** for everything |
| GitLab.com                       | One **group service account** per top-level group                                          | One token per group          |

Prefer the single instance service account wherever you administer the instance. GitLab.com has no such thing, so each top-level group there brings its own account and token.

With an instance service account you do not need to tell Lunar which groups it serves. It reads that from the account's memberships: every group where the account holds the Maintainer role, each covering its whole subtree. Lunar follows those memberships as they change, so you bring a group into scope by inviting the service account to it. A group service account's token, by contrast, is bound to its group in the Hub configuration (the `group` field below), and Lunar only uses it there. Where several tokens are configured for the same scope, Lunar load-balances its read traffic across them.

### Create the service account

On **GitLab Dedicated or self-managed**, an instance administrator does this once:

1. Go to **Admin → Settings → Service accounts** and select **Add service account**.
2. Name it something recognizable, e.g. `Earthly Lunar`. The account appears as the author of Lunar's merge-request comments and commit statuses.
3. Add the account to **every top-level group** Lunar should serve, with the **Maintainer** role (each group's **Manage → Members → Invite members**). A membership covers all of the group's subgroups and projects, and it is how Lunar decides what to serve. Inviting the account to a new group later onboards that group without any configuration change.

On **GitLab.com**, a group Owner does it once per top-level group:

1. In the group, go to **Settings → Service accounts** and select **Add service account**.
2. Add the account to the group with the **Maintainer** role.

### Create the token

On the service accounts page, select the vertical ellipsis (**⋮**) next to the account, then **Manage access tokens → Add new token**. Give it the `api` scope, and pick an expiry (see [Expiry and rotation](#expiry-and-rotation) below).

What each token can do decides which features are available for the groups it serves:

| Permission | Value        | What it enables                                                                                                                                                                                          |
| ---------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| scope      | `api`        | Everything Lunar does over the API: reading repositories, commits, and merge requests; posting commit statuses and MR comments                                                                           |
| role       | `Maintainer` | Registering project webhooks, maintaining [project badges](#project-badges), and on Ultimate provisioning the [merge gate](#merge-gate): the status check and the **Status checks must succeed** setting |

Copy the token from GitLab. You leave this section with either a **single token** (instance service account) or **one token per top-level group** (GitLab.com).

### Expiry and rotation

GitLab access tokens carry a fixed expiry (by default at most a year out), and the Hub reads each token once at startup.

Set a calendar reminder ahead of the expiry date. When a token lapses, Lunar stops posting results and stops reacting to webhooks for the groups it served, and because the Hub only reads tokens at startup, nothing fails loudly to tell you.

A service account can hold several tokens at once, so replacement is a rollover rather than a cutover: create the new token next to the old one on the **Manage access tokens** page, deliver it, and revoke the old one once the Hub is running with the new. The delivery mechanics depend on your install: [self-hosted](/install/lunar-hub/self-hosted/day-2-operations#git-platform-credentials) is a secret update plus a Hub restart; on [Dedicated](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets) you re-deposit the token into your install's secret drop.

{% hint style="warning" %}
GitLab 19.2 added an instance setting that **enforces fine-grained tokens after a chosen date**, which blocks the creation and rotation of classic-scope tokens. Service accounts cannot use fine-grained tokens yet, so if your instance plans to turn that enforcement on, keep its date clear of Lunar's rotation schedule until GitLab extends them to service accounts.
{% endhint %}

## Hub configuration (Dedicated)

There is nothing to configure: Earthly runs the Hub and configures it for you. Continue at [Dedicated → Deposit your credentials](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets), where the token goes to your install rather than to Earthly. What you deposit is what the steps above produced:

* **GitLab Dedicated or self-managed**: the instance service account's single token, alongside your instance hostname.
* **GitLab.com**: one token per **group service account**.

Everything below is for self-hosted installs; you can skip to [Merge gate](#merge-gate).

## Hub configuration (self-hosted)

Lunar reads GitLab credentials from `HUB_GITLAB_TOKENS`, a JSON array with one entry per token, typically a single entry holding the instance service account's token. [Install Step 4](/install/lunar-hub/self-hosted/install-walkthrough#git-platform-credentials) has the values block to paste; this is the field reference behind it.

### Entry fields

| Field            | Required | Meaning                                                                                                                                                                                                                                                                                                                                           |
| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `token_path`     | Yes      | Where the token file is mounted inside the Hub container.                                                                                                                                                                                                                                                                                         |
| `group`          | No       | Binds the token to one top-level group or subgroup, which it serves together with everything beneath it. Omit it for an instance service account: the token then serves every group on `host` that no `group` entry claims. A `group` entry always takes precedence over a group-less one, and the most specific `group` wins when several match. |
| `webhook_secret` | No       | Overrides the chart-generated signing secret (`<release>-gitlab-webhook`) for the groups this entry serves. Set it when one group should not be able to forge deliveries for another; leave it out otherwise. Entries that form a pool (same host and `group`) must agree on it; the hub refuses to start otherwise.                              |
| `host`           | No       | Defaults to `gitlab.com`. Set it to your instance hostname for self-managed.                                                                                                                                                                                                                                                                      |
| `base_url`       | No       | Defaults to `https://<host>/api/v4`. Set it only if your instance serves the API somewhere else.                                                                                                                                                                                                                                                  |

A token without `group` must be a Maintainer everywhere Lunar reaches on that host: Lunar does not fall back to another token when a call it makes with that account is refused. On GitLab.com, where each token belongs to a group service account, set `group` on every entry.

{% hint style="warning" %}
**A self-managed host must be listed here.** Lunar decides whether a host is GitLab from this configuration. If you omit `host` for a self-managed instance, Lunar treats that host as GitHub, and every operation against it fails in confusing ways. `gitlab.com` is recognized without configuration.
{% endhint %}

### Multiple groups

What this looks like depends on which GitLab you run.

#### GitLab.com: one entry per group

There is no instance-wide service account on GitLab.com, so every top-level group brings its own account and its own `group` entry:

```yaml
hub:
  extraEnv:
    - name: HUB_GITLAB_TOKENS
      value: >-
        [{"group":"acme","token_path":"/secrets/gitlab/acme.token"},
         {"group":"globex","token_path":"/secrets/gitlab/globex.token"}]
```

#### GitLab Dedicated or self-managed

The instance service account serves every group it is a Maintainer of, so one group-less entry covers them all. Invite the account to a new group and nothing here changes:

```yaml
hub:
  extraEnv:
    - name: HUB_GITLAB_TOKENS
      value: '[{"host":"gitlab.example.com","token_path":"/secrets/gitlab/lunar-sa.token"}]'
```

One group can still take a token of its own, for example a sensitive group that should show a separate bot in its audit trail. A `group` entry wins over the group-less one, so the shared account never touches that group:

```yaml
hub:
  extraEnv:
    - name: HUB_GITLAB_TOKENS
      value: >-
        [{"host":"gitlab.example.com","token_path":"/secrets/gitlab/lunar-sa.token"},
         {"host":"gitlab.example.com","group":"finance","token_path":"/secrets/gitlab/finance-bot.token"}]
```

### More rate limit for a busy scope

Repeating a scope (the same `group`, or the same `host` with no `group`) pools its tokens: Lunar spreads read traffic (commits, merge requests, project metadata) across the pool and uses the first entry listed for everything it writes, so comments, statuses, and webhooks always come from one identity. To give a busy subgroup more rate limit without changing who serves the rest of the instance, add a second account to that subgroup and list both tokens under its `group`:

```yaml
hub:
  extraEnv:
    - name: HUB_GITLAB_TOKENS
      value: >-
        [{"host":"gitlab.example.com","token_path":"/secrets/gitlab/lunar-sa.token"},
         {"host":"gitlab.example.com","group":"platform/checkout","token_path":"/secrets/gitlab/lunar-sa.token"},
         {"host":"gitlab.example.com","group":"platform/checkout","token_path":"/secrets/gitlab/checkout-sa.token"}]
```

Here `platform/checkout` draws on both accounts' rate limits, and everything else stays on the shared account alone. Give the accounts in a pool the same role, so that what Lunar can do in a group does not depend on which token serves a call.

{% hint style="info" %}
**Scaling tip.** GitLab applies API rate limits per account. If the whole instance is busy, add a second instance service account as another group-less entry; if one group or subgroup pushes constantly, pool extra tokens under its `group` as above. (On a self-managed instance you can also raise the limits; on gitlab.com you cannot.)
{% endhint %}

## Merge gate

Lunar can block a merge request on every tier. What differs is the mechanism, how precisely it targets Lunar, and who switches it on.

| Tier          | Mechanism                                                 | Blocking                                                                                                                            |
| ------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Ultimate      | [External status check](#external-status-checks-ultimate) | Lunar provisions and enables it for you                                                                                             |
| Free, Premium | [Commit status](#commit-status-default)                   | Available, but you enable **Pipelines must succeed** yourself, and it gates on the whole pipeline rather than on Lunar specifically |

Only policies at a gating enforcement level participate. See [policies](/configuration/lunar-config/policies). Whichever mechanism you are on, a blocked merge request can be let through by [overriding a block](#overriding-a-block).

### External status checks (Ultimate)

On **GitLab Ultimate**, Lunar blocks a merge request whose gating policies do not pass.

{% hint style="warning" %}
**There is no setting to turn this on, and Lunar changes your projects to enable it.** Read what it changes below before rolling Lunar out to an Ultimate namespace.
{% endhint %}

Merge blocking is capability-driven: Lunar detects that a namespace is on Ultimate and switches on automatically. There is no configuration flag, so if you upgrade a namespace's licence later, blocking activates on the next sync without anyone touching Lunar's configuration.

To make blocking work, Lunar changes each project it tracks:

| Change                                       | Where you see it in GitLab                    |
| -------------------------------------------- | --------------------------------------------- |
| Creates a status check named `Earthly Lunar` | **Settings → Merge requests → Status checks** |
| Enables **Status checks must succeed**       | **Settings → Merge requests**                 |

**Lunar re-applies these settings on every sync.** Turning them off in the GitLab UI is temporary; they come back. This is deliberate: it keeps the gate from being quietly disabled on one project and drifting out of policy. But it does mean that switching the gate off is not a supported way to stop it; see [Turning the gate off](#turning-the-gate-off).

This requires the service account to hold the `Maintainer` or `Owner` role, since Lunar creates the status check and changes merge-request settings.

#### Turning the gate off

The gate is **fail-closed**. If Lunar is unavailable, gated merge requests stay blocked rather than falling open. That is the safe default for a compliance control, but it means you should know the lever before you need it.

To unblock merges during an incident, a project **Owner** disables **Status checks must succeed** in **Settings → Merge requests**.

### Commit status (default)

On **GitLab Free and Premium** there are no external status checks, so Lunar posts a commit status named `Earthly Lunar` instead. Lunar provisions no merge gate on these tiers, and changes no merge-request setting. It still writes the status, registers the project webhook, and maintains [project badges](#project-badges), as it does on Ultimate.

A commit status folds into the commit's pipeline result, so it **can** gate merges, but you enable that yourself, per project, in **Settings → Merge requests → Merge checks → Pipelines must succeed**. With it on, a failing Lunar status makes the pipeline fail and the merge request unmergeable.

Two consequences worth knowing before relying on it:

* **It is not Lunar-specific.** The setting gates on the pipeline as a whole, so a Lunar failure and a broken test look the same to it, and you cannot require Lunar without also requiring everything else in the pipeline. The Ultimate status check is a named requirement; this is not.
* **Pending counts as not-succeeded.** Lunar reports `running` while a commit's checks are still evaluating, so a merge request stays unmergeable until Lunar resolves. That is the point, and it is why [overriding a block](#overriding-a-block) forces the status green for a commit rather than waiting for the evaluation to finish.

### Overriding a block

A blocked merge request can be overridden without leaving the thread: comment `/lunar bypass: <reason>` to override the gate, or `/lunar bypass rm` to take an override back. See the [PR/MR comments reference](/docs/pr-comments) for the commands, the Maintainer bar they demand, and the audit trail they leave. A [`lunar policy bypass-pr`](/docs/lunar-cli#lunar-policy-bypass-pr) from the CLI clears the same gate.

This works on every tier, against whichever mechanism the project has: on Ultimate it answers the status check, and on Free and Premium it writes the commit status **Pipelines must succeed** reads.

## MR status reporting

On a merge request, Lunar reports each evaluation as:

* **A named result**: the `Earthly Lunar` status check on Ultimate, or a commit status on Free and Premium. See [Merge gate](#merge-gate) for which of those can block.
* **A merge-request comment** summarising the policy results, updated in place as new commits arrive rather than posted repeatedly.

Reporting depends on webhooks, which the Hub registers on each project automatically, on every tier. The hook subscribes to push, merge-request, and comment events; the comment events are what carry the [`/lunar` commands](/docs/pr-comments).

## Main branch status reporting

On commits to the default branch, Lunar posts a **commit status**, on every tier.

Status checks exist only on merge requests, so there is no Ultimate-specific behaviour here and nothing to configure: default-branch reporting looks the same whichever tier you are on. These statuses are informational; there is no merge to block.

### Project badges

Lunar maintains **project badges** that reflect the latest default-branch result, so a project's standing is visible from its overview page without opening a commit or a dashboard. Each links to the dashboard behind it.

* **Release readiness**, one per project. It reads `release ready` when nothing blocks a release, `release blocked` when something does, `release bypassed` when the only things blocking are covered by an active [`lunar policy bypass-release`](/docs/lunar-cli#lunar-policy-bypass-release), and `evaluating` while results are still arriving. A partial bypass still reads `release blocked`.
* **One per initiative**, showing that initiative's score for the component.

Lunar gates releases but never observes one, so `release ready` means nothing is blocking, not that anything shipped. A component with no release-gating policies reads `release ready`.

The default-branch commit status is deliberately left at its un-bypassed verdict, so a bypassed project reads as blocked on the commit and overridden on the badge.

On a monorepo, the badges belong to the component whose name carries no subdirectory, and the release badge is omitted, since release readiness is per component and the project has several. A monorepo with no whole-repo component gets no badges.

Lunar provisions badges with the service account's token, as it does the merge gate's status check, so the account needs the same `Maintainer` or `Owner` role.

## Next steps

Self-hosted installs carry the token into a Kubernetes secret at [prereqs Step 6](/install/lunar-hub/self-hosted/prerequisites#step-6-plan-your-kubernetes-secrets), and into your chart values at [install Step 4](/install/lunar-hub/self-hosted/install-walkthrough#git-platform-credentials). On Dedicated you [deposit it](/install/lunar-hub/dedicated/setup#step-4-deposit-your-secrets) into your install's secret drop instead.

After that, set up [config sync from GitLab CI](/install/lunar-hub/self-hosted/sync-config#gitlab-ci) so the Hub's copy of your configuration stays current as you edit it. [Git Platforms](/install/git-platforms) has the full capability comparison with GitHub.


# Lunar CI Tracer

Which CI platforms the Lunar CI Tracer can trace (GitHub Actions, Buildkite, and GitLab CI), and where the setup instructions for each live.

The **Lunar CI Tracer** instruments your CI runners: it wraps the runner process, watches what a build actually does, and triggers collectors at the right moments. That is how build-time facts reach the Hub, including test results, scan output, image digests, and deploy markers.

It installs on your runners rather than in the cluster, so it is independent of the Hub install and can be added at any point after it.

CI tracing is optional. Lunar still collects from pushes and merge requests without it; the tracer is what adds the build-time half.

| CI platform        | Support                                     | Setup                                                                                                                                                                                                                                   |
| ------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **GitHub Actions** | Supported                                   | [Self-hosted runners](/install/ci-tracer/github-actions-self-hosted): install the tracer on the runner. [GitHub-managed runners](/install/ci-tracer/github-actions-managed): add the `earthly/lunar-ci-tracer` action to your workflow. |
| **Buildkite**      | Supported                                   | [Buildkite](/install/ci-tracer/buildkite): an agent command hook plus a Notification Service webhook.                                                                                                                                   |
| **GitLab CI**      | [Coming soon](/install/ci-tracer/gitlab-ci) | Nothing to install yet. Submit build-time facts from a job with [`lunar collect`](/docs/lunar-cli#lunar-collect) in the meantime.                                                                                                       |

## Choosing between the two GitHub Actions options

Both put the same tracer on the runner; they differ in where the install happens.

* [**The action**](/install/ci-tracer/github-actions-managed) is per workflow job, and works on GitHub-hosted *and* self-hosted runners. It is the easier starting point, and the only option when you do not control the runner image.
* [**Installing on the runner**](/install/ci-tracer/github-actions-self-hosted) is per-machine, so every workflow on that runner is traced without touching any workflow YAML. It needs self-hosted runners you administer.

## Shared configuration

* [Configuration reference](/install/ci-tracer/configuration-reference) covers every environment variable the tracer reads, on any platform.
* [Systemd](/install/ci-tracer/systemd) covers running the tracer as a service on a Linux host.

## Next steps

* [Git Platforms](/install/git-platforms) for the credentials Lunar uses to read repositories and post results.
* [Install Lunar](/install) for where CI integration fits in the overall install.


# GitHub Actions (Self-Hosted)

Install the Lunar CI Tracer on self-hosted runners to instrument CI/CD pipelines and collect metadata during builds, tests, scans, and deploys.

The Lunar CI Tracer instruments CI/CD pipelines to collect metadata during builds, tests, scans, and deployments. It wraps your existing runner process, monitors execution, and triggers scripts at the right moments.

This page covers the most common setup: **self-hosted runners** (including GitHub Actions self-hosted runners). If you're using GitHub-hosted managed runners, see [Managed Runners](/install/ci-tracer/github-actions-managed).

## Prerequisites

You need an existing self-hosted runner infrastructure (e.g. GitHub Actions self-hosted runner). Lunar adds instrumentation on top of your existing setup — it does not manage the runner lifecycle.

## Adding to an Existing Runner

{% stepper %}
{% step %}

## Download and install the Lunar CLI

<a href="https://github.com/earthly/lunar-dist/releases/latest" class="button primary" data-icon="download">Download the Lunar CLI</a>

Or via the command line:

```bash
curl -LO https://github.com/earthly/lunar-dist/releases/download/v2.5.0/lunar-linux-amd64
chmod +x lunar-linux-amd64 && sudo mv lunar-linux-amd64 /usr/local/bin/lunar
```

{% hint style="info" %}
Replace the version above with the latest from the [releases page](https://github.com/earthly/lunar-dist/releases/latest).
{% endhint %}

The CI tracer is no longer installed separately. The CLI fetches it on first use through your Lunar Hub (see the last step), so the only binary you install here is `lunar`.
{% endstep %}

{% step %}

## Set the required environment variables

```bash
export LUNAR_CI_TYPE=github
export LUNAR_HUB_TOKEN=your_hub_token
export LUNAR_HUB_HOST=your_hub_host
export LUNAR_HUB_GRPC_PORT=your_grpc_port
export LUNAR_HUB_HTTP_PORT=your_http_port
export LUNAR_RUN_CMD=path_to_github_runner_run.sh
```

The tracer auto-detects state, cache, and bundle directories based on the running user:

* **Root** → system paths (`/var/lib/lunar`, `/var/cache/lunar/git-repos`, `/var/tmp/lunar/...`).
* **Non-root** → user paths under `$HOME/.lunar/`.

Override any of them by setting `LUNAR_STATE_DIR`, `LUNAR_GIT_CACHE_DIR`, `LUNAR_BUNDLE_DIR`, `LUNAR_SNIPPET_DIR`, `LUNAR_SCRIPT_LOG_DIR`, `LUNAR_BIN_DIR`, or `LUNAR_LOCK_DIR` — useful for read-only rootfs runners, systemd units with `ProtectHome=`, or non-standard home directories.
{% endstep %}

{% step %}

## Run the tracer

```bash
lunar ci-tracer run
```

On first run, `lunar ci-tracer run` downloads the tracer through your Hub, verifies it, caches it under `LUNAR_BIN_DIR` (`$HOME/.lunar/bin` by default), and then runs it. Subsequent runs reuse the cached binary and skip the download.

{% hint style="info" %}
The Hub must be reachable the first time you run this on a host (or whenever the cache is empty). Once the tracer binary is cached, the tracer only needs the Hub for its normal runtime traffic.
{% endhint %}

{% hint style="info" %}
**Upgrading from an earlier agent install?** Previous versions shipped a separate `lunar-ci-agent` binary that you downloaded and started yourself. That separate download no longer exists — install only the `lunar` CLI and start the tracer with `lunar ci-tracer run`, which fetches the tracer for you.
{% endhint %}

{% hint style="info" %}
For production usage, run `lunar ci-tracer run` under a process supervisor such as `systemd` so it restarts automatically on failure. See [Systemd Configuration](/install/ci-tracer/systemd) for an example unit file.
{% endhint %}
{% endstep %}
{% endstepper %}

## Using a Custom Runner Image

If your runners are containerized, install the `lunar` CLI inside a `Dockerfile` and make `lunar ci-tracer run` the entrypoint. The tracer binary is fetched on first boot through your Hub — the build host does not need Hub connectivity:

{% code title="Dockerfile" %}

```dockerfile
FROM my-custom-runner-image:ubuntu-slim

ENV LUNAR_HUB_HOST=my.cool.host.com
ENV LUNAR_HUB_GRPC_PORT=443
ENV LUNAR_HUB_HTTP_PORT=443
ENV LUNAR_CI_TYPE=github
ENV LUNAR_RUN_CMD=/home/ubuntu/actions-runner/run.sh

# Replace /home/ubuntu with your runner user's home directory
ENV LUNAR_STATE_DIR=/home/ubuntu/.lunar/state
ENV LUNAR_GIT_CACHE_DIR=/home/ubuntu/.lunar/git-repos
ENV LUNAR_BUNDLE_DIR=/home/ubuntu/.lunar/bundles
ENV LUNAR_SNIPPET_DIR=/home/ubuntu/.lunar/snippets
ENV LUNAR_SCRIPT_LOG_DIR=/home/ubuntu/.lunar/scripts
ENV LUNAR_BIN_DIR=/home/ubuntu/.lunar/bin
ENV LUNAR_LOCK_DIR=/home/ubuntu/.lunar/lock

RUN curl -LO https://github.com/earthly/lunar-dist/releases/download/v2.5.0/lunar-linux-amd64 && \
    chmod +x lunar-linux-amd64 && mv lunar-linux-amd64 /usr/local/bin/lunar

ENTRYPOINT ["lunar", "ci-tracer", "run"]
```

{% endcode %}

On the first container boot, `lunar ci-tracer run` accesses your Lunar Hub to download and cache the tracer under `LUNAR_BIN_DIR`, then runs it. Subsequent boots reuse the cached binary **only if `LUNAR_BIN_DIR` is persisted** across container restarts (e.g. a mounted volume) — otherwise each fresh container downloads the tracer again on first boot.

{% hint style="info" %}
**Optional: warm the cache at build time.** Run `lunar ci-tracer install` during the build to download and cache the tracer without starting it. This requires the build host to reach the Hub. Pass the Hub token as a build secret so it never lands in an image layer:

```dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=lunar_hub_token \
    LUNAR_HUB_TOKEN="$(cat /run/secrets/lunar_hub_token)" \
    lunar ci-tracer install
```

```bash
docker build --secret id=lunar_hub_token,env=LUNAR_HUB_TOKEN .
```

The tracer is cached in `LUNAR_BIN_DIR`, so it's baked into the image layer. Make sure `LUNAR_BIN_DIR` points at a path that stays in the image.
{% endhint %}

{% hint style="info" %}
The GitHub Actions runner may not work correctly when run as root. See GitHub's [self-hosted runner documentation](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners) for details.
{% endhint %}

Bake everything into the image except secrets. Pass the Hub token at runtime:

```bash
docker run -e LUNAR_HUB_TOKEN=$(vault read /lunar/hub/token) runner-image:latest
```

Or in Kubernetes, reference a Secret:

```yaml
env:
  - name: LUNAR_HUB_TOKEN
    valueFrom:
      secretKeyRef:
        name: lunar-hub
        key: token
```

For all environment variable details, see the [Configuration Reference](/install/ci-tracer/configuration-reference).

***

## Running `sudo` and setuid binaries in traced workflows

To trace command execution, the tracer installs a seccomp filter on the runner process. Linux only permits installing that filter when the process either holds the `CAP_SYS_ADMIN` capability or has the `no_new_privs` flag set. When the runner does **not** have `CAP_SYS_ADMIN`, the tracer falls back to setting `no_new_privs`, which makes the kernel ignore the setuid bit. Non-root processes can then no longer escalate through setuid-root binaries — `sudo`, for example, refuses to run:

```
sudo: The "no new privileges" flag is set, which prevents sudo from running as root.
```

`no_new_privs` is inherited by every child process and can never be cleared, so this affects any unprivileged user your workflow switches to, not just the top-level runner user.

To keep `sudo` and other setuid binaries working under tracing, run the runner **as root** in a container that adds `CAP_SYS_ADMIN`. The tracer detects the capability on the runner process and skips `no_new_privs`, installing the seccomp filter through the capability instead.

{% code title="docker" %}

```bash
docker run --cap-add SYS_ADMIN runner-image:latest
```

{% endcode %}

{% code title="kubernetes" %}

```yaml
securityContext:
  capabilities:
    add: ["SYS_ADMIN"]
```

{% endcode %}

{% hint style="info" %}
The runner must run **as root** for this to take effect: a capability added to a container is only held by a root (uid 0) process. A non-root runner process does not gain `CAP_SYS_ADMIN` from the container, so the tracer still falls back to `no_new_privs` there.
{% endhint %}

{% hint style="warning" %}
`CAP_SYS_ADMIN` is a broad, privileged capability — grant it only when your workflows genuinely need setuid escalation (e.g. `sudo`) under tracing. On [GitHub-hosted managed runners](/install/ci-tracer/github-actions-managed) the job runs as the non-root `runner` user with no way to hold `CAP_SYS_ADMIN` — its passwordless `sudo` is a sudoers grant, not a capability — so setuid escalation is not supported while tracing there.
{% endhint %}

***

## Next Steps

Once installed, you can begin configuring:

* [Collectors](/configuration/lunar-config/collectors) to gather SDLC data
* [Policies](/configuration/lunar-config/policies) to enforce standards
* [Domains and Components](/docs/key-concepts) to organize your software landscape

For questions or enterprise onboarding:

<a href="https://earthly.dev/earthly-lunar/demo" class="button secondary" data-icon="envelope">Contact the Earthly team</a>


# GitHub Actions (Managed)

Use the Lunar CI Tracer action to instrument GitHub-hosted and self-hosted GitHub Actions runners with the Lunar CI Tracer.

The [`earthly/lunar-ci-tracer`](https://github.com/earthly/lunar-ci-tracer) action is the easiest way to add the Lunar CI Tracer to your GitHub Actions workflows. It works with both **GitHub-hosted** and **self-hosted** runners.

For **GitHub-hosted runners** (managed runners), this action is the only installation method — you cannot modify the runner startup process.

For **self-hosted runners**, you can either use this action or configure the tracer to [wrap the runner's `run.sh` command](/install/ci-tracer/github-actions-self-hosted) directly, which avoids adding a step to every job.

## Setup

Add the Lunar CI Tracer action as an early step in your workflow jobs:

```yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Run Lunar CI Tracer
        id: lunar
        uses: earthly/lunar-ci-tracer@<latest-tag>
        env:
          LUNAR_HUB_TOKEN: ${{ secrets.LUNAR_HUB_TOKEN }}
          LUNAR_HUB_HOST: your_hub_host

      - uses: actions/checkout@v5
      # ... rest of your workflow
```

The action installs the `lunar` CLI for you (no pre-install step needed) and runs `lunar ci-tracer run`, which fetches the tracer through your Lunar Hub on first use, verifies it, and attaches it to the job process. All subsequent steps in the job are automatically instrumented. Your Hub must be reachable when the action starts.

## How It Works

The action runs as a step in a job. It downloads the `lunar` CLI, then runs `lunar ci-tracer run`, which fetches the tracer through your Hub on first use and execs it. The tracer attaches to the current shell process via ptrace and traces all commands executed by subsequent steps. The tracer exits automatically when the job completes.

The same [configuration reference](/install/ci-tracer/configuration-reference) applies. The only difference is that `LUNAR_RUN_CMD` is not needed — the action handles process supervision internally.

{% hint style="warning" %}
For self-hosted runners that require the use of `sudo` in workflows, the `CAP_SYS_ADMIN` capability should be provided — see [Running `sudo` and setuid binaries in traced workflows](/install/ci-tracer/github-actions-self-hosted).
{% endhint %}

## Failure Handling

Tracer installation failures (CLI download, tracer download through the Hub, tracer startup) are gated by `LUNAR_STRICT_MODE`:

* `LUNAR_STRICT_MODE=true` — the step **fails** with the error output.
* Unset or `false` (default) — the action emits an `::error::` annotation plus a warning, sets the `agent-installed` output to `false`, and the step **succeeds** so the rest of the job continues uninstrumented.

The action exposes an `agent-installed` output (`'true'` / `'false'`) so downstream steps can branch on whether instrumentation is active:

```yaml
- name: Run checks that need the tracer
  if: steps.lunar.outputs.agent-installed == 'true'
  run: ./run-traced-tests.sh
```


# Buildkite

Run Lunar on Buildkite — trace builds with a Buildkite agent command hook and deliver build status to Lunar Hub with a Notification Service webhook.

Lunar instruments Buildkite with two pieces:

1. A Buildkite agent **`command` hook** (`command` is Buildkite's agent-hook type) that wraps each command with Lunar tracing, so collectors run and their data is attributed to your components.
2. A Buildkite **Notification Service webhook** to Lunar Hub, so Lunar Hub knows a build ran and CI-dependent policies wait for it instead of finalizing early.

{% hint style="info" %}
Lunar listens to **two** webhooks that drive different things: your **source-control (SCM)** webhook carries source events (push / pull request) and owns component identity, while the **Buildkite** webhook reports that a build ran so CI-dependent policies wait for it. Buildkite is the CI layer; your SCM is the source host — Buildkite tracing works against the source host your components are already registered with.
{% endhint %}

{% hint style="warning" %}
Buildkite support assumes **GitHub-hosted repositories**. Component attribution and changed-path inference both resolve against GitHub, so Buildkite pipelines building GitLab projects are not supported. See [Git Platforms](/install/git-platforms).
{% endhint %}

## Setup

### Step 1 — Agent command hook (tracing)

On your Buildkite agent, add a `command` hook (in the agent's `hooks/` directory) that exports the Lunar configuration and runs `lunar ci-tracer run` to trace the build's command:

```bash
# hooks/command
set -e

export LUNAR_CI_TYPE=buildkite
export LUNAR_RUN_CMD="bash -c \"$BUILDKITE_COMMAND\""
export LUNAR_HUB_HOST=hub.example.com
export LUNAR_HUB_TOKEN="$LUNAR_HUB_TOKEN"

lunar ci-tracer run
```

The `lunar` CLI installs the CI tracer on first use and caches it, so no separate tracer binary needs to be installed on the runner. Source-control credentials (used for changed-file inference) are fetched from Lunar Hub, so no GitHub token is needed on the runner.

The Hub host and ports resolve in order: explicit `LUNAR_HUB_*` env vars, then the `hub:` block of a `lunar-config.yml` the runner can read, then a default of `443` for both gRPC and HTTP. Set `LUNAR_HUB_GRPC_PORT` / `LUNAR_HUB_HTTP_PORT` only for a Hub on non-default ports that isn't declared in a manifest. See the [Configuration Reference](/install/ci-tracer/configuration-reference) for the full set of `LUNAR_*` variables.

### Step 2 — Notification Service webhook (build status)

Without the webhook, Lunar Hub never learns a Buildkite build happened, so CI-dependent policies would finalize before the build's data lands. In Buildkite, go to **Organization Settings → Notification Services → Add → Webhook** (the service is org-level, not per-pipeline) and configure it so Lunar Hub records each build:

* **URL**: `https://<your-hub-host>/webhooks/buildkite`
* **Token**: set a shared token and configure Lunar Hub with `HUB_BUILDKITE_WEBHOOK_TOKEN` to the same value. Lunar Hub verifies the `X-Buildkite-Token` header on every request; an unset token rejects all requests.
* **Events**: `build.scheduled`, `build.running`, `build.finished`.

{% hint style="warning" %}
The webhook endpoint must be reachable from Buildkite's servers, so it's exposed to the public internet — the same posture as your SCM webhook endpoint. Keep it behind TLS and rely on the shared `X-Buildkite-Token` for authentication; Lunar Hub rejects every request whose token doesn't match.
{% endhint %}

Lunar Hub creates a workflow-run record per build, associates it with the build's commit (and pull request, for PR builds), and re-evaluates CI-dependent policies when a build finishes.

## Component attribution

Lunar reads the standard `BUILDKITE_*` environment to attribute collected data to components. In a monorepo, set one of the following in your pipeline so a build maps to the right subdirectory component (see [Components](/configuration/lunar-config/components)):

* `LUNAR_COMPONENT` — name the component(s) explicitly (comma-separated).
* `ciPipelines` on the component — matches the Buildkite **pipeline name**.
* `LUNAR_COMPONENT_INFER=true` — infer from changed paths (PR builds) or the working directory.

## Scope and limitations

* **Command-level** collection is supported (`ci-before/after-command` hooks). Job- and step-level scopes are not yet collected by Lunar.
* **Changed-path** component inference resolves for **pull-request** builds; push builds fall back to working-directory inference (Buildkite provides no prior-commit SHA to diff against).
* **One CI provider per repo** — the first Buildkite build marks the repo as Buildkite-driven, and any other CI provider's doneness probe then skips it. Running two CI providers against the same repo isn't supported.


# GitLab CI

GitLab CI tracing support in Lunar is coming soon.

**Coming soon.** The Lunar CI tracer does not support GitLab CI yet, so there is nothing to install on a GitLab Runner.

In the meantime, Lunar collects on GitLab from pushes and merge requests, and build-time facts can be submitted from a pipeline job with [`lunar collect`](/docs/lunar-cli#lunar-collect).


# Configuration Reference

Reference for all environment variables that configure the Lunar CI Tracer, including required settings, Docker options, and state directories.

## Required

| Variable              | Description                                                                                                                                                                                                                                                  |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `LUNAR_HUB_TOKEN`     | Auth token for your Hub installation.                                                                                                                                                                                                                        |
| `LUNAR_HUB_HOST`      | Hostname of your Hub installation — a bare host name such as `hub.example.com`. Must be reachable from the runner.                                                                                                                                           |
| `LUNAR_HUB_GRPC_PORT` | Hub's gRPC port. Used for configuration sync, collection results, and SCM token resolution.                                                                                                                                                                  |
| `LUNAR_HUB_HTTP_PORT` | Hub's HTTP port. Used for log uploads and script downloads.                                                                                                                                                                                                  |
| `LUNAR_CI_TYPE`       | CI platform type: `github` (GitHub Actions) or `buildkite`. `gitlab` coming soon (see [GitLab CI](/install/ci-tracer/gitlab-ci)). For Buildkite setup, see [Buildkite](/install/ci-tracer/buildkite).                                                        |
| `LUNAR_RUN_CMD`       | Command to start the runner process. For GitHub Actions self-hosted runners, this is the path to `run.sh` (e.g. `/home/ubuntu/actions-runner/run.sh`). Not needed when using the [managed runners](/install/ci-tracer/github-actions-managed) GitHub Action. |

## Optional

| Variable                            | Default      | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| ----------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LUNAR_HUB_INSECURE`                | `false`      | Set to `true` when connecting to a Hub instance without TLS.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `LUNAR_UPDATE_PERIOD`               | `15s`        | How often the tracer polls Lunar Hub for configuration updates. Each poll asks only for what has changed since the tracer's last one, so a poll that finds nothing new costs a single small request.                                                                                                                                                                                                                                                                                                                                                  |
| `LUNAR_CATALOG_FULL_REFRESH_PERIOD` | `15m`        | How often the tracer re-reads the component catalog in full instead of asking only for changes, as a backstop against a change being missed by the incremental check. Jobs do not depend on it: the tracer re-reads the catalog for a repository whenever a job for that repository starts. Lower it to converge faster at the cost of more traffic; `0` disables it.                                                                                                                                                                                 |
| `LUNAR_CATALOG_FETCH_TIMEOUT`       | `10s`        | How long the tracer will wait, at the start of a job, for an up-to-date catalog of the repository being built. This read is unconditional, so a job always starts against a current catalog for its own repository. On timeout the tracer proceeds with the catalog it already has rather than failing the job. `0` turns off per-repository fetching, so the tracer keeps a copy of the whole catalog instead.                                                                                                                                       |
| `LUNAR_LOG_LEVEL`                   | `info`       | Log verbosity. Set to `debug` for troubleshooting.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `LUNAR_GITHUB_HOST`                 | `github.com` | The GitHub host whose components this tracer collects for (the `<host>` in `<host>/<org>/<repo>` component names). Auto-detected from `GITHUB_SERVER_URL` when the tracer runs as a GitHub Actions step (including via the [Lunar CI Tracer action](/install/ci-tracer/github-actions-managed)), so **GitHub Enterprise Server "just works"** there. Set it explicitly only when the tracer can't see `GITHUB_SERVER_URL` — e.g. when wrapping a self-hosted runner's `run.sh` directly — or to override. (Replaces the former `LUNAR_GIT_BASE_URL`.) |

## Advanced

### Docker

These options are for environments where collectors or policies run in Docker containers (e.g. private registries, custom networks, or sidecar Docker daemons).

| Variable                     | Default  | Description                                                                |
| ---------------------------- | -------- | -------------------------------------------------------------------------- |
| `LUNAR_DOCKER_REGISTRY_USER` | *(none)* | Username for a private Docker registry containing collector/policy images. |
| `LUNAR_DOCKER_REGISTRY_PASS` | *(none)* | Password for a private Docker registry containing collector/policy images. |
| `LUNAR_DOCKER_NETWORK`       | *(none)* | Docker network for script container execution.                             |

### State Directories

The tracer uses several directories for state, caching, and execution. The defaults listed below are the root-user paths. When running as a non-root user, the tracer automatically falls back to `$HOME/.lunar/` paths (e.g. `$HOME/.lunar/state` instead of `/var/lib/lunar`), so manual overrides are usually unnecessary. You can still set these variables explicitly if you need non-standard locations.

| Variable               | Default                      | Description                                           |
| ---------------------- | ---------------------------- | ----------------------------------------------------- |
| `LUNAR_STATE_DIR`      | `/var/lib/lunar`             | Script execution state and embedded runtimes.         |
| `LUNAR_GIT_CACHE_DIR`  | `/var/cache/lunar/git-repos` | Cached git repository clones.                         |
| `LUNAR_BUNDLE_DIR`     | `/var/tmp/lunar/bundles`     | Component JSON bundles for policy evaluation.         |
| `LUNAR_SNIPPET_DIR`    | `/var/lib/lunar/snippets`    | Downloaded script code from Hub.                      |
| `LUNAR_SCRIPT_LOG_DIR` | `/var/tmp/lunar/scripts`     | Script execution logs (uploaded to Hub).              |
| `LUNAR_BIN_DIR`        | `/usr/lib/lunar`             | Embedded runtime binaries.                            |
| `LUNAR_LOCK_DIR`       | `/run/lock/lunar`            | Installation lock files to prevent parallel installs. |


# Systemd

Run Lunar as a systemd service on Linux hosts with a sample unit file for reliable startup, restarts, and environment configuration.

Systemd is a system and service manager for Linux that provides a standardized way to define and control how services start, stop, and behave on boot.

For Lunar, systemd may be used to ensure that the application is reliably managed, automatically started at boot, and cleanly shut down or reloaded when needed.

Here's a simple systemd unit file that will run `lunar` in a resilient way.

{% code title="lunar.service" %}

```ini
[Unit]
Description=Lunar CI Tracer
After=network.target

[Service]
Type=simple
WorkingDirectory=/home/ubuntu
ExecStart=/usr/local/bin/lunar ci-tracer run
EnvironmentFile=/etc/lunar.env
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

{% endcode %}


# AI Skills

Install AI agent skills for Claude Code, Codex, Cursor, and 50+ other agents to help build Lunar collectors, policies, and SQL queries.

The [earthly/skills](https://github.com/earthly/skills) repository provides AI agent skills for working with Lunar. These skills enable AI assistants to help you build custom plugins, edit your `lunar-config.yml`, and query Lunar's data model.

## Available Skills

| Skill                                                                                 | Description                                                                                                         |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| [lunar-collector](https://github.com/earthly/skills/tree/main/skills/lunar-collector) | Create Lunar collector plugins (Bash scripts) that gather SDLC metadata                                             |
| [lunar-cataloger](https://github.com/earthly/skills/tree/main/skills/lunar-cataloger) | Create Lunar cataloger plugins (Bash scripts) that build the software catalog from external systems or repo signals |
| [lunar-policy](https://github.com/earthly/skills/tree/main/skills/lunar-policy)       | Create Lunar policy plugins (Python scripts) that enforce engineering standards                                     |
| [lunar-config](https://github.com/earthly/skills/tree/main/skills/lunar-config)       | Edit `lunar-config.yml` — wire together components, domains, collectors, policies, catalogers, and initiatives      |
| [lunar-sql](https://github.com/earthly/skills/tree/main/skills/lunar-sql)             | Craft SQL queries against Lunar's data model (components, checks, policies, domains, PRs)                           |

## Installation

Install with the [`skills`](https://github.com/vercel-labs/skills) CLI:

```bash
# Install all Lunar skills globally
npx skills add earthly/skills -g

# Or install a specific skill globally
npx skills add earthly/skills -g --skill lunar-policy
```

The CLI auto-detects which coding agents you have installed (Claude Code, Codex, Cursor, and 50+ more) and copies the skills to the right location. See the [`skills` CLI docs](https://github.com/vercel-labs/skills) for more commands like `list`, `update`, and `remove`.

Drop `-g` to install into the current project's `.claude/skills/` (or equivalent) instead — useful if you want to commit the skills into a shared team repo.

## Usage

These skills are designed to be used with AI agents that support the Claude/Codex skill format. Each skill contains:

* `SKILL.md` - Main instructions and quick-start guide
* `references/` - Curated documentation for the AI to consult as needed

Once installed, your AI assistant will automatically detect and use these skills when you ask it to build a Lunar plugin, edit your config, or query the SQL API.

## Related Resources

* [Bash SDK](/plugin-sdks/bash-sdk) - Manual reference for building collectors and catalogers
* [Python SDK](/plugin-sdks/python-sdk) - Manual reference for building policies
* [lunar-config.yml](/configuration/lunar-config) - Manual reference for the configuration file
* [SQL API](/sql-api/sql-api) - Manual reference for querying Lunar data
* [lunar-lib Repository](https://github.com/earthly/lunar-lib) - Reference collectors, catalogers, and policies


# Learn the basics

Walk through your first Lunar setup — populating components, defining policies, and running checks against your repositories.

This guide will help you understand the basic concepts of Lunar and get started with monitoring your engineering practices.

## Prerequisites

Before you begin, make sure you have:

1. Installed Lunar following the [installation guide](/install)
2. Access to your code repositories
3. Basic understanding of your CI/CD pipeline setup

## Basic Concepts

Lunar operates on a few key concepts:

1. **Components**: These are your software projects (services, libraries, repositories)
2. **Collectors**: These gather information about your components
3. **Policies**: These define rules and standards for your components
4. **Checks**: These are the results of policy evaluations

## Your First Lunar Setup

{% stepper %}
{% step %}

## Populate your Lunar configuration

Start by creating a `lunar-config.yml` file in your project root:

{% code title="lunar-config.yml" %}

```yaml
version: 0

hub:
  host: <host>
  grpcPort: <grpc-port>
  httpPort: <http-port>

# Use the official Lunar image for running scripts in containers
default_image: earthly/lunar-scripts:1.0.0

domains:
  team1:
    description: Main organization domain

components:
  github.com/my-org/my-service:
    owner: jane@example.com
    domain: team1

collectors:
  - name: readme-lines
    runBash: |-
      if [ -f ./README.md ]; then
        lunar collect -j \
          "repo.readme_exists" true \
          "repo.readme_num_lines" "$(wc -l < ./README.md)"
      else
        lunar collect -j "repo.readme_exists" false
      fi
    hook:
      type: code

policies: []
```

{% endcode %}

The `default_image` setting runs all collectors and policies inside Docker containers using the official `earthly/lunar-scripts` image. This image includes Python, Bash, the `lunar` CLI, and the `lunar-policy` package pre-installed. For more details on image configuration, see [Images](/configuration/lunar-config/images).

You will need to replace `github.com/my-org/my-service` with a real repository you want to monitor. On GitLab the component name includes the full namespace — `gitlab.com/my-group/my-service`, or `gitlab.com/my-group/my-subgroup/my-service` for a project in a subgroup. See [Components](/configuration/lunar-config/components) for the naming rules.

{% hint style="info" %}
Lunar can also auto-discover components from external systems like GitHub, Backstage, and other sources using catalogers. See the [catalogers documentation](/configuration/lunar-config/catalogers) for details on setting up automated component discovery. For now, this example will focus on manually declared components.
{% endhint %}

Commit this code to a new repository called `lunar`. To apply this configuration, run the following command:

```bash
# GitHub
lunar hub pull github://my-org/lunar@main

# GitLab 
lunar hub pull gitlab://gitlab.com/my-group/lunar@main
```

You should be able to see the new domain, the new component, and its component JSON being populated in the Lunar UI.
{% endstep %}

{% step %}

## Define your first policy

Add a policy to check your component:

{% code title="lunar-config.yml" %}

```yaml
policies:
  - name: readme
    description: "README.md standards"
    on: ["domain:team1"]
    runPython: |-
      from lunar_policy import Check
      with Check("readme-exists", "Repository should have a README.md file") as c:
        c.assert_true(c.get_value(".repo.readme_exists"), "README.md file not found")
```

{% endcode %}

Since we're using the official `earthly/lunar-scripts` image (via `default_image`), the [Lunar Policy SDK](/plugin-sdks/python-sdk/policy) is already pre-installed—no `requirements.txt` needed.

Commit the code, and apply the new configuration:

```bash
lunar hub pull github://my-org/lunar@main
# ... or gitlab://gitlab.com/my-group/lunar@main
```

You should be able to see the new policy, and the checks being populated for this component in the Lunar UI.

Congratulations! You've just set up your first Lunar collector and policy.
{% endstep %}
{% endstepper %}

## See also

1. Learn more about [key concepts](/docs/key-concepts)
2. Explore [configuration options](/configuration/lunar-config)
3. Browse [200+ pre-built guardrails](https://earthly.dev/lunar/guardrails/) and [50+ integrations](https://earthly.dev/lunar/integrations/) instead of writing everything from scratch
4. Install [AI skills](/install/skills) for Claude Code, Codex, or Cursor to help build collectors and policies


# Key concepts

Overview of Lunar's core concepts — domains, components, collectors, catalogers, policies, and checks — and how they fit together.

This page describes the most important concepts in Lunar.

At a high-level, your software is organized into components (services or libraries), which often map to code repositories, or subdirectories in monorepos. The engineering processes of each software component in your system is instrumented by collectors. The collectors gather the information into a component JSON. Policies are then defined to create guardrails around your engineering practices. The policies are evaluated against the component JSON and create checks that have a pass or fail outcome.

## Domains

Domains are used to group related components together. Domains are hierarchical and can contain other domains.

Domains are useful to model complex inter-related systems, like multi-service applications, or hierarchical teams in large organizations.

## Components

Components are the building blocks of Lunar. They represent the entities that Lunar monitors. Components are independent pieces of software, such as microservices, or libraries.

Components usually correspond to git repositories, but they may also correspond to subdirectories in a monorepo.

## Component JSON

The Component JSON is an object that contains collected SDLC (software development lifecycle) metadata about a component. Collectors associated with a component contribute to the component JSON metadata via "metadata deltas". Together, all these deltas form a complete picture of the component in a merged JSON representation.

Examples of information that can be collected include:

* Project configuration information
* Project ownership information
* Code access control and branch protection configuration
* Detailed build and test status, including coverage reports
* Security scan results
* Software bill of materials (SBOM)
* Software composition analysis (SCA) results
* Infrastructure information
* Production deliverables and their metadata

For more information about the component JSON, see the [Component JSON](/docs/component-json) page.

## Collectors

Collectors are SDLC (software development lifecycle) instrumentation configurations. They are used to collect live information from the SDLC to associate with individual components.

Collectors are of different **types** that vary depending on the **hook** used. They can be based on code, cron schedules, CI/CD hooks, or data from other collectors.

Once a hook is triggered, the collector **executes its custom logic**, which collects data from the SDLC. The data is later on merged to form the component metadata JSON.

Collectors may execute in different **contexts**, depending on the type - for example, in the context of a CI pipeline, or standalone in an ephemeral runner reacting to code changes. The Lunar CI Tracer and the Lunar Runner are some of the pieces of the Lunar framework that facilitate the triggering and executions of collectors in such contexts.

Lunar ships with [50+ pre-built integrations](https://earthly.dev/lunar/integrations/) for tools like GitHub, Kubernetes, Docker, Codecov, Snyk, and more. To read more about how to configure collectors, see the [Collectors](/configuration/lunar-config/collectors) page.

### Earthly Lunar CI Tracer

The Earthly Lunar CI Tracer allows you to trigger and execute collectors related to CI/CD pipelines running on a CI runner. The tracer is installed on the self-hosted runner of the CI (GitHub Actions or Buildkite) and is responsible for instrumenting the pipelines executing on that host and running arbitrary logic defined by relevant collectors.

Installation on managed CI runners is also possible, although it requires additional setup in each project via the definition of the CI pipelines (typically the YAML definition). Earthly Lunar comes out of the box with policies to verify that the tracer is installed properly in such situations.

The Lunar CI Tracer is able to instrument individual processes executing within CI pipelines and is able to surgically inject custom logic safely in a way that does not interfere with the CI pipeline's normal execution. Special hooks are defined to trigger collectors to execute in the context of specific processes, no matter how deep in the process tree hierarchy these appear.

### Earthly Lunar Runner

The Earthly Lunar Runner is a standalone ephemeral runner that can be used to execute code-based or cron-based collectors.

You can think of the Lunar Runner as "global CI" where the logic is defined centrally via the Lunar configuration, and not specified within each project's CI pipeline definition. This allows a central platform team to collect information about the entire organization's codebases, running arbitrary logic, and using arbitrary scanners, tools, and scripts, without needing to modify each project's CI pipeline or needing to request permission from individual app teams.

## Policies

Policies are used to define the rules that Lunar uses to evaluate the health of components. Policies receive the component metadata as input and return checks. The checks can then be reported to a scorecarding system or monitored via the Lunar UI.

Some policies may help provide immediate feedback to developers via the PR status. In such a situation, Lunar typically shows up as a commit status check entry in the PR (similarly to how a CI might report status in a PR), showing the health of the component based on the policies.

Similarly, policies can also be used to block deployments based on the health of the component. This is useful to prevent deploying components that do not meet certain engineering standards of the organization.

Lunar includes [200+ pre-built guardrails](https://earthly.dev/lunar/guardrails/) covering testing, security, compliance, and operational readiness. For more information on how to configure policies, see the [Policies](/configuration/lunar-config/policies) page.

## Checks

Checks are the results of evaluating a policy against a component. You can think of Lunar checks as individual line item policies that appear in the final scorecard of a component.

Checks might have different outcomes, such as:

* `pass` - the policy check passes successfully
* `fail` - the policy check is failing
* `pending` - the data required by the policy check is still pending (e.g. a collector has not finished executing yet)
* `skipped` - the policy check was skipped

## Catalogers

Catalogers are used to synchronize component and/or domain data from external systems, such as other code repositories, databases, REST APIs, or IDPs, such as Backstage. Browse the [available integrations](https://earthly.dev/lunar/integrations/) for pre-built catalogers.

To read more about how to configure catalogers, see the [Catalogers](/configuration/lunar-config/catalogers) page.

## Catalog JSON

The Catalog JSON is an object that contains the component and domain data collected by the catalogers.

To read more about the Catalog JSON, see the [Catalog JSON](/docs/catalog-json) page.


# Guides

Opinionated guides for rolling out Lunar in a large engineering organization, beyond the reference documentation.

The rest of the documentation describes what each part of Lunar does. These guides describe what to actually do with them — the sequencing, the trade-offs, and the failure modes we see most often in large engineering organizations.

* [Cataloging Strategy](/docs/guides/cataloging-strategy) — how to get from a partial, inconsistent software catalog to one complete and trustworthy enough to scope guardrails with. Covers coverage, cleanup, monorepos, and the guardrails that keep the catalog honest.

Guides assume you have already [installed Lunar](/install) and read [Key concepts](/docs/key-concepts).


# Cataloging Strategy

How to build a complete, trustworthy software catalog in Lunar — achieving full coverage, cleaning up inconsistent existing catalog data, handling monorepos, and enforcing catalog quality with guardra

Lunar decides what to enforce, and where, from your catalog. Domains determine the reporting structure, and tags determine which collectors and policies apply to which component through [`on` expressions](/configuration/lunar-config/on). A component that is missing, misfiled, or mistagged is a component your guardrails silently skip.

That makes catalog quality the first thing to work on in a Lunar rollout. In most large organizations only a fraction of repositories ship to production, handle regulated data, or fall in scope for a compliance audit, and targeting your strictest guardrails at that fraction is what keeps them credible with the teams they apply to.

You do not need a good catalog to start. Most organizations do not have one, and the steps below are designed to be run against a messy or largely absent catalog — Lunar becomes the thing that gets you to a good one, and starts returning findings while you are still working on it.

## What you are starting from

Most organizations we work with already have a catalog of some kind. It typically has three problems.

**It is incomplete.** Registering a service was a convention, not a requirement, and nothing enforced it. A meaningful share of repositories were never cataloged at all.

**It is inconsistent.** Entries were filled in by individual teams over several years, against a schema that drifted. Owners point at people who left. Tags mean different things in different divisions. Nothing has ever validated the data, because nothing has ever depended on it programatically.

**It does not identify monorepo components uniquely.** A monorepo's catalog entries were written for a system that keys on a service name. Lunar keys on a repository URL plus an optional subdirectory, and most existing catalogs carry no field that maps cleanly onto that.

None of this was a problem before, because the catalog was a directory that people read. Lunar is the first system to make automated, consequential decisions from it, and that is a much higher bar.

The catalog data itself usually comes in one of three shapes, and the strategy below works for all of them:

| Shape                              | Example                                             | Source of truth             |
| ---------------------------------- | --------------------------------------------------- | --------------------------- |
| **Catalog service with an API**    | Backstage, an internal service registry             | A server you can query      |
| **Files distributed across repos** | `catalog-info.yaml` in each repository              | The repositories themselves |
| **Central metadata repository**    | One repo of YAML/TOML/JSON describing every service | A single repository         |

Backstage is used as the running example because it is the more common, and because Lunar ships plugins for it. The same phases apply to a home-grown catalog; only the plugins differ. Where a shipped plugin does not fit your format, the [AI skills](/install/skills) for Claude Code, Codex, and Cursor are the fastest way to produce the collector, policy, or cataloger you need — they know Lunar's SDKs and conventions, so pointing one at your existing catalog schema gets you most of the way.

## The shape of the operation

At the highest level this is three moves, in order:

{% stepper %}
{% step %}

### Ingest every repository, ignoring the existing catalog

Get 100% of your repositories into Lunar as components before looking at any pre-existing catalog data. You immediately know the true size of the estate.
{% endstep %}

{% step %}

### Validate and fix the catalog data at scale

Pull the existing catalog entries in as raw data and run guardrails against them, without letting them shape your catalog yet. Developers get feedback in their pull requests on exactly what is wrong with their own entries, and the cleanup happens in parallel across the organization instead of as a central project.
{% endstep %}

{% step %}

### Ingest the clean catalog

Once the data is trustworthy, let it drive Lunar's components, domains, and tags — then enrich it with what nobody wrote down.
{% endstep %}
{% endstepper %}

## The steps

| #     | Step                                                                               | Primitive | Required  | Purpose                                            |
| ----- | ---------------------------------------------------------------------------------- | --------- | --------- | -------------------------------------------------- |
| **1** | **Ingest every repository**                                                        |           |           |                                                    |
| 1.1   | [Discover every repository](#11-discover-every-repository)                         | Cataloger | Yes       | Establish 100% coverage as the floor               |
| **2** | **Validate and fix the catalog data**                                              |           |           |                                                    |
| 2.1   | [Collect the existing catalog data](#21-collect-the-existing-catalog-data)         | Collector | Yes       | Pull raw catalog entries into Lunar                |
| 2.2   | [Validate structure and identity](#22-validate-structure-and-component-identity)   | Policy    | Yes       | Developer feedback; monorepo uniqueness            |
| 2.3   | [Require a catalog entry](#23-require-a-catalog-entry)                             | Policy    | Optional  | Close the coverage gap continuously                |
| **3** | **Ingest the clean catalog**                                                       |           |           |                                                    |
| 3.1   | [Ingest the catalog](#31-ingest-the-catalog-into-lunar)                            | Cataloger | Yes       | Turn validated data into components, domains, tags |
| 3.2   | [Validate the ingested catalog](#32-validate-the-ingested-catalog)                 | Policy    | Optional  | Enforce semantics beyond structure                 |
| 3.3   | [Enrich with heuristics](#33-enrich-with-heuristics)                               | Cataloger | Optional  | Classify what nobody wrote down                    |
| 3.4   | [Declare paths for monorepo components](#34-declare-paths-for-monorepo-components) | Cataloger | Monorepos | Make change detection correct in monorepos         |

## Phase 1. Ingest every repository

### 1.1 Discover every repository

Start with a cataloger that enumerates your source control and creates one component per repository. This is your coverage floor, and it is what makes 100% coverage achievable on day one rather than as the outcome of a migration.

{% code title="lunar-config.yml" %}

```yaml
catalogers:
  - name: github-org
    uses: github://earthly/lunar-lib/catalogers/github-org@v1.12.0
    with:
      org_name: acme
      default_owner: platform@acme.com
      default_domain: engineering
      include_archived: "false"
```

{% endcode %}

Set `default_domain`. Every component then lands under a known root domain, which gives you a handle that matches everything: [domain tags match hierarchically](/configuration/lunar-config/on#domain-tags), so `on: ["domain:engineering"]` covers `engineering` and every subdomain beneath it.

Repeat the cataloger once per organization if you have several. See the [GitHub Org cataloger documentation](https://earthly.dev/lunar/integrations/catalogers/github-org/) for all the available settings.

At this point every repository exists as a component, everything is in one domain, and nothing is classified. That is the correct starting state. The remaining steps add more meaning.

{% hint style="info" %}
**Monorepos at this stage.** The GitHub org cataloger creates a single component for the monorepo root (`github.com/acme/monorepo`). Lunar has no awareness of the internal breakdown yet. Phase 2 is what begins interpreting the monorepo structure; step 3.1 is what turns it into real subcomponents.
{% endhint %}

## Phase 2. Validate and fix the catalog data

### 2.1 Collect the existing catalog data

Write a collector that reads your existing catalog entries out of each repository and writes them into the [Component JSON](/docs/component-json) verbatim. Do not interpret or reshape the data here. The point is to make the raw entry visible so a policy can judge it.

Lunar ships a collector for Backstage's `catalog-info.yaml`; a home-grown format needs an equivalent collector of your own.

{% code title="lunar-config.yml — Backstage example" %}

```yaml
collectors:
  - uses: github://earthly/lunar-lib/collectors/backstage@v1.12.0
    on: ["domain:engineering"]
```

{% endcode %}

The component JSON convention is to write the raw descriptor under `.catalog.native.<tool>`, where the *presence* of the key is itself the signal: if the collector finds no entry, it writes nothing, and the absence means "not cataloged".

Use a `code` hook so this re-runs whenever the repository changes, which is what makes the feedback in step 2.2 immediate.

**In monorepos, collect the whole tree onto the repository-level component.** The subcomponents do not exist yet, so a collector scoped to a single directory would find nothing. Have the collector gather every entry in the repository and write them as a set onto the root component. That is what lets step 2.2 see all the entries at once and check them against each other. The repository-level component has no `paths` restriction, so it runs on any change anywhere in the monorepo.

How you gather that set depends on how the monorepo declares its components. Two layouts are common:

**One entry file per component directory** — `services/payments/catalog-info.yaml`, `services/web/catalog-info.yaml`, and so on. Walk the tree and record each entry *together with the path of the file it came from*. That path is what makes each component's identity derivable in step 2.2, so carry it through rather than discarding it after parsing.

**One shared entry file listing every component** — a single root `catalog-info.yaml`, typically a multi-document YAML or a list of entities, declaring every service in the repository. Collect the whole file and keep every entity in it. The catch is that there is no per-component file location to fall back on here: every entry shares one path, so the subdirectory has to come from a field *inside* each entry. If no such field exists today, this is the layout that forces you to introduce one. See [Component identity](#component-identity) below.

The two layouts often coexist in the same organization, and can coexist in the same repository. You may handle both in the collector rather than mandating one, since consolidating a monorepo's catalog files is a migration in its own right and not necessarily a prerequisite for getting started.

A central metadata repository behaves like the shared-file layout: one location, many entries, so identity has to be carried in a field. The same collector logic applies, driven from the central repo rather than from each component's own tree.

### 2.2 Validate structure and component identity

Now add a policy that validates the raw entries the collector gathered. This is the step that turns catalog quality into something developers see and act on, because policies report into pull requests and catalogers do not.

{% code title="lunar-config.yml — Backstage example" %}

```yaml
policies:
  - uses: github://earthly/lunar-lib/policies/backstage@v1.12.0
    name: catalog-structure
    initiative: cataloging
    on: ["domain:engineering"]
    include: [catalog-info-exists, catalog-info-valid, owner-set, lifecycle-set, system-set]
    enforcement: report-pr
```

{% endcode %}

{% hint style="info" %}
The checks in that plugin are written against the `catalog-info.yaml` schema, so they only apply if your catalog is Backstage-shaped. For a home-grown catalog the structure of this step is identical, but the assertions are yours to write — see the [Python SDK](/plugin-sdks/python-sdk/policy), or use the [AI skills](/install/skills) to generate a policy plugin against your own schema.

Whichever route you take, the checks in [Component identity](#component-identity) below are the ones that are not optional.
{% endhint %}

#### Component identity

Lunar identifies a component by repository URL plus an optional subdirectory. The grammar is positional:

```
github.com/<org>/<repo>[/<subdir>]        # GitHub
gitlab.com/<namespace...>/<project>[/-/<subdir>]   # GitLab
```

On GitHub the first three segments are always host, org, and repo; everything after is the subdirectory. On GitLab, namespaces nest arbitrarily, so the `/-/` marker is what separates the project from the subdirectory. See [Components](/configuration/lunar-config/components) for the full rules.

Every component in a monorepo therefore needs its own distinct subdirectory path — `github.com/acme/monorepo/services/payments`, `github.com/acme/monorepo/services/web`. Deciding where that subdirectory comes from is the main piece of design work in a monorepo migration, and it is worth doing deliberately rather than discovering it later.

#### Where the subdirectory comes from

{% hint style="info" %}
If your monorepos use **one entry file per component directory**, the subdirectory is inherent in the layout (it is the directory the file sits in) and you can skip to [the collision danger](#why-identity-collisions-are-dangerous) below. The rest of this section matters when the file's location does not identify the component: a **shared entry file** listing many components, or a sync from the catalog API, where every entry arrives with the same location or none at all.
{% endhint %}

In Backstage, three annotations are candidates, and the one most people reach for first is the one that cannot work:

* **`github.com/project-slug` is repo-level by definition.** Its [documented format](https://backstage.io/docs/features/software-catalog/well-known-annotations/) is `owner/repo`, with no path component, and the processor that auto-populates it derives it from the repository alone. Every component in a monorepo receives an identical value. This is also the default `component_id_annotation` for Lunar's Backstage catalogers, so leaving it at the default is exactly how a monorepo collapses into a single component.
* **`backstage.io/source-location`** ***can*****&#x20;carry the subdirectory, but frequently does not.** Backstage's built-in location processor resolves it relative to the entity's own location, so an entry in `services/payments/` auto-populates to `url:https://github.com/acme/monorepo/tree/main/services/payments/`. It is also documented as hand-writable, for the case where the catalog file does not sit with the source it describes. In practice, though, a great many real catalogs have this annotation pointing at the repository root anyway — hand-written once and never revisited, copied between services, or carried over from a migration. **Treat it as a hint, not a fact.** Validate it rather than trusting it, which is exactly what the next section is for.
* **`backstage.io/managed-by-location` is a weaker fallback.** It is always present and points at the entry file, so its directory is recoverable. But Backstage documents it as many-to-one — a single location can be the source of many entities — and does not guarantee the value is even a `url` type, so it cannot be the primary key in the shared-file layout.

That leaves three realistic options:

| Option                         | How identity is derived                                        | When it fits                                                                                  |
| ------------------------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| **The entry file's location**  | The directory containing each entry file *is* the subdirectory | One entry file per component. Correct by construction, nothing to maintain, nothing to trust. |
| **An existing location field** | Parse `source-location` down to a repo-relative path           | A shared entry file, or syncing from the catalog API — provided you validate the values first |
| **A dedicated field**          | A new annotation holding the subdirectory verbatim             | The existing fields are unreliable, or you want the value explicit and reviewable             |

Deriving from the entry file's location is the best option wherever it applies, precisely because it depends on no field anyone has to maintain. It is what Lunar's monorepo cataloger does.

The choice between repurposing `source-location` and introducing a dedicated annotation usually comes down to how much of your existing data is already correct. Measure that first — step 2.2 gives you the number — and repurpose only if the answer is "most of it". Introducing a new field costs one backfill; adopting a field that is quietly wrong for a third of your services costs you a wrong catalog that looks right.

Both annotation-based routes also need a parsing step: Lunar's API-based Backstage cataloger concatenates `component_id_prefix` with the raw annotation value, so it needs a field already in bare `owner/repo/subdir` form. A full `url:https://…` value cannot be pointed at directly.

{% hint style="info" %}
Repurposing `backstage.io/source-location` is safe. TechDocs resolves against `backstage.io/techdocs-ref`, and the catalog page's view and edit links are governed by `backstage.io/view-url` and `backstage.io/edit-url`, so none of them depend on it. Several tools in the ecosystem already read this annotation for exactly this purpose — scoping themselves to a component's subdirectory within a monorepo.
{% endhint %}

The shared-entry-file layout from step 2 needs particular care here. Every entity in that file shares one location, so the auto-populated `source-location` is identical for all of them and points at the repository root. Auto-derivation cannot disambiguate them at all, which makes a hand-written `source-location` or a dedicated field mandatory in that layout rather than a preference.

#### Why identity collisions are dangerous

If two catalog entries resolve to the same component id, Lunar merges them into one component, and the merge is **silent and lossy in a specific way**:

* Scalar fields — `owner`, `domain`, `branch`, `description` — take the value of whichever cataloger ran last.
* Array fields — `tags`, `paths`, `ciPipelines` — are **concatenated**.

The array behavior is the one that causes real damage. Two colliding services produce one component holding the union of both tag sets, so a service that is not production can inherit `production` from its collision partner and pull strict guardrails onto itself, while its partner's `owner` is quietly overwritten with the wrong team. Nothing errors, and the result looks plausible in the UI.

{% hint style="danger" %}
This is the single most important check to get right in a monorepo. Assert it before the data reaches your cataloger, not after.
{% endhint %}

Your policy should assert, over the full set of entries the collector found:

1. Every entry resolves to a **distinct** component id. This is the check that makes the identity decision above real, and the only one that catches a collision before it silently merges.
2. Whatever field you chose to carry the subdirectory is **present** on every entry in a monorepo. An entry that omits it falls back to the repository root and collides with every other entry that does the same.
3. That field is **actually specific to the component**, not left at the repository root. This is the check that catches a stale `source-location`, and in a monorepo a root-pointing value is indistinguishable from a missing one.
4. Each id is **well-formed** for your git platform — three segments plus a subdirectory on GitHub, `/-/` separated on GitLab.
5. The subdirectory **corresponds to a real directory** in the repository, and where you derive identity from entry-file locations, that it matches the file's own directory. This is what keeps identity stable when the repository is reorganized.
6. Required fields are present and typed correctly.

Start at `enforcement: report-pr` so teams see the problems without being blocked, then escalate to `block-pr` once the backlog is worked down. Group the policies under a `cataloging` [initiative](/configuration/lunar-config/initiatives) so the effort is trackable as a unit.

### 2.3 Require a catalog entry

*Optional, but this is the step that closes the coverage gap permanently.*

Because step 1.1 made every repository a component, you can now enforce that every component has a catalog entry — the mechanism your organization previously lacked.

{% code title="lunar-config.yml — Backstage example" %}

```yaml
policies:
  - uses: github://earthly/lunar-lib/policies/backstage@v1.12.0
    name: catalog-coverage
    initiative: cataloging
    on: ["domain:engineering"]
    include: [catalog-info-exists]
    enforcement: score
```

{% endcode %}

Roll this out along the [enforcement ladder](/configuration/lunar-config/policies#enforcement): `score` to measure the gap without touching anyone, `report-pr` to surface it in pull requests, then `block-pr` once coverage is high enough that blocking is fair.

## Phase 3. Ingest the clean catalog

### 3.1 Ingest the catalog into Lunar

Now add the cataloger that reads your existing catalog and maps it onto Lunar's components, domains, and tags. The source can be the same files the collector read, or the catalog service's API if the server adds value your files do not have — resolved group hierarchies, defaulted namespaces, entity relations.

Catalogers run on a [hook](/configuration/lunar-config/cataloger-hooks). A scheduled full sync is the usual backbone — catalog data changes slowly, and a nightly pass is simple to reason about and self-healing. Use a `repo` hook instead if your source is a central metadata repository, so the catalog refreshes on merge rather than on a schedule.

You can also pair the nightly sync with a commit-triggered cataloger where your source is repo-resident files. The [Backstage catalog-info.yaml cataloger](https://earthly.dev/lunar/integrations/catalogers/backstage-catalog-info/) ships both by default: a scheduled `augment`, and an `augment-on-commit` that re-reads the file as soon as the repo is committed to, so an edited `catalog-info.yaml` lands immediately rather than at the next nightly tick.

#### Available Backstage catalogers

Lunar ships four catalogers relevant to this step. They are designed to layer, not to compete:

| Cataloger                                                                                                               | Source                              | What it does                                                                                                                    |
| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| [`github-org`](https://earthly.dev/lunar/integrations/catalogers/github-org/)                                           | GitHub API                          | One component per repository. The coverage floor from step 1.1.                                                                 |
| [`backstage`](https://earthly.dev/lunar/integrations/catalogers/backstage/)                                             | Backstage REST API                  | Components and domains from the running server, with `subdomainOf` / `spec.system` hierarchy resolved into dotted domain paths. |
| [`backstage-catalog-info`](https://earthly.dev/lunar/integrations/catalogers/backstage-catalog-info/)                   | `catalog-info.yaml` per repo        | Augments each existing component from its own committed file.                                                                   |
| [`backstage-catalog-info-monorepo`](https://earthly.dev/lunar/integrations/catalogers/backstage-catalog-info-monorepo/) | Every `catalog-info.yaml` in a repo | Creates one component per file, keyed to that file's directory. The monorepo answer.                                            |

{% code title="lunar-config.yml — Backstage example" %}

```yaml
catalogers:
  # Repo-level components, from each repository's own catalog-info.yaml.
  - uses: github://earthly/lunar-lib/catalogers/backstage-catalog-info@v1.12.0
    with:
      default_domain: engineering

  # Monorepo subcomponents, one per discovered catalog-info.yaml.
  - uses: github://earthly/lunar-lib/catalogers/backstage-catalog-info-monorepo@v1.12.0
    with:
      orgs: acme
      allowed_topics: monorepo
```

{% endcode %}

Running those two together is the intended monorepo setup: the first owns the repository-level component, the second adds the subcomponents. The monorepo cataloger's `exclude_paths` default skips the root entry, so the two never write the same component id.

If you sync from the Backstage API instead, `component_id_annotation` is the bridge between your catalog's identity and Lunar's — and, per [Component identity](#where-the-subdirectory-comes-from) above, its default of `github.com/project-slug` is repo-level only. Point it at a field that carries the subdirectory before running it against a monorepo.

For a home-grown catalog you write the cataloger yourself. Here is a basic example.

{% code title="lunar-config.yml — central metadata repo" %}

```yaml
catalogers:
  - name: central-catalog
    runBash: |-
      cat domains.yaml | yq -o=json | lunar catalog raw --json '.domains' -
      cat services.yaml | yq -o=json | lunar catalog raw --json '.components' -
    hook:
      type: repo
      repo: github://acme/software-catalog
```

{% endcode %}

#### Mapping onto Lunar's model

Three mapping decisions are worth making deliberately:

**Domains.** Lunar's domains are hierarchical and dotted (`engineering.payments.ledger`). If your catalog expresses hierarchy by reference rather than by path — Backstage's `subdomainOf` and `spec.system`, for example — the cataloger has to resolve those references into a dotted path. Sync every level of the hierarchy, or a reference to an unsynced parent falls back to a bare name and the tree flattens.

**Tags.** Namespace imported tags with a prefix, which the shipped catalogers do by default (`bs-` for Backstage, `gh-` for GitHub topics). This keeps imported data distinguishable from tags you assign deliberately, and it doubles as gap detection: a component carrying `gh-` tags but no `bs-` tags is provably absent from Backstage, and `on:` supports `NOT`, so you can target exactly that population.

**Precedence.** Catalogers merge in declaration order, last one wins, and then `lunar.yml` 🚧 Coming Soon and `lunar-config.yml` are applied on top. Declare the broad, low-confidence source first and the specific, high-confidence source last.

`lunar-config.yml` being the highest-precedence layer is what gives the central platform team a way to augment and correct the catalog without waiting on the upstream system. The merge is per field, so setting `owner` on a component there overrides just that field — the domain, tags, and everything else keep tracking the cataloger. Use it to pin a value you know the upstream has wrong, or to add metadata the upstream cannot express, and leave the rest to sync.

### 3.2 Validate the ingested catalog

*Optional.*

Structural validity is not the same as being correct. Once the data is flowing, add policies for the semantics that only matter because Lunar now depends on them.

{% code title="lunar-config.yml — Backstage example" %}

```yaml
policies:
  - uses: github://earthly/lunar-lib/policies/backstage@v1.12.0
    name: catalog-semantics
    initiative: cataloging
    on: ["domain:engineering"]
    include: [domain-exists, system-exists, required-annotations, required-tag-patterns]
    with:
      required_tag_patterns: "tier/*,data-classification/*"
    enforcement: report-pr
```

{% endcode %}

The checks worth having here are referential integrity — a component pointing at a domain or system that does not exist — and required tag patterns, which is how you force the classification decisions that scoping depends on. Requiring a `data-classification/*` tag makes every team state whether their service handles sensitive data, which is exactly the input you need before you can scope a compliance guardrail to it.

### 3.3 Enrich with heuristics

*Optional, and the highest-leverage of the optional steps if large parts of your catalog are unclassified.*

Some of what you need to know was never written down anywhere. Rather than asking every team to backfill it, derive it.

A `component-cron` cataloger can read a component's accumulated [Component JSON](/docs/component-json) and classify from evidence Lunar already collected — a deployment in a production Kubernetes namespace, a release pipeline that has actually run, an ingress reachable from the internet:

{% code title="lunar-config.yml" %}

```yaml
catalogers:
  - name: production-detection
    description: Tag components with observed production deployments
    runBash: |-
      JSON="$(lunar component get-json "$LUNAR_COMPONENT_ID")"
      if echo "$JSON" | jq -e '.k8s.deployments[]? | select(.namespace == "prod")' >/dev/null; then
        lunar catalog raw --json ".components.\"$LUNAR_COMPONENT_ID\".tags" '["production"]'
      fi
    hook:
      type: component-cron
      schedule: "0 3 * * *"
```

{% endcode %}

A `component-repo` cataloger with `clone-code: true` does the same from repository signals rather than collected data — the presence of a deploy workflow, a Helm chart, a Terraform production variable file.

Treat heuristics as a supplement, not a replacement. Where a heuristic and a declared value disagree, that disagreement is itself worth a check: a component that deploys to production but is declared `experimental` is a catalog defect, and often an interesting one.

### 3.4 Declare paths for monorepo components

*Optional, and only relevant if you have monorepos.*

The `paths` field tells Lunar which file changes affect which component. In a monorepo, this determines whether a component's collectors run at all when a commit lands.

A monorepo component automatically gets an implicit `<subdir>/*` pattern derived from its own name, and by default that is the whole of its trigger set. A component whose only pattern is `services/payments/*` **will not re-evaluate** when a shared library it depends on changes. Its checks stay green against stale data. The commit is recorded as handled for that component rather than left pending, so nothing hangs and nothing warns — the result is simply out of date.

`paths` is how you widen that set. The implicit subdirectory pattern is always present, so entries you add are **additional** triggers on top of it: in a monorepo, `paths` only ever expands what the component reacts to, and can never narrow it below its own subdirectory. Declare the shared code and configuration that should also trigger re-evaluation:

{% code title="lunar-config.yml" %}

```yaml
components:
  github.com/acme/monorepo/services/payments:
    paths:
      - libs/common/*        # shared library the service depends on
      - build/bazel/*        # shared build configuration
      - catalog-info.yaml    # shared catalog file at the repo root
```

{% endcode %}

Matching is deliberately simple: a single trailing `*` is a string prefix match, and an entry without one must match a changed path exactly.

{% hint style="info" %}
The one case where `paths` narrows rather than widens is a **repository-level** component — one with no subdirectory in its name, and so no implicit pattern. Those match every change by default, and setting `paths` on one restricts it to just the entries you list. Worth knowing, but it is the opposite of the monorepo case above and rarely what you want.
{% endhint %}

**Who should own this.** The people who know which shared code a component depends on are the component's owners, not the platform team. Maintaining these lists centrally in `lunar-config.yml` does not scale and goes stale immediately. Put the field in the catalog file the team already edits — `catalog-info.yaml`, `lunar.yml` 🚧 Coming Soon, or your internal equivalent — and wire it through the pipeline you have already built:

* **Step 2.1** collects the declared paths along with the rest of the entry.
* **Step 2.2** validates them: correct type, paths that exist, no attempt to reach outside the repository.
* **Step 3.1** emits them to `.components["<id>"].paths` so Lunar's change detection actually uses them.

Without that last wiring the field is inert documentation. It has to reach the Catalog JSON to have any effect.

## Putting it together

{% code title="lunar-config.yml" %}

```yaml
version: 0

hub:
  host: lunar.acme.com
  grpcPort: 443
  httpPort: 443

default_image: earthly/lunar-scripts:1.0.0

domains:
  engineering:
    description: Acme engineering
    owner: platform@acme.com

catalogers:
  # 1.1 Coverage floor: every repo becomes a component.
  - uses: github://earthly/lunar-lib/catalogers/github-org@v1.12.0
    with:
      org_name: acme
      default_owner: platform@acme.com
      default_domain: engineering

  # 3.1 Existing catalog, mapped onto Lunar's model.
  - uses: github://earthly/lunar-lib/catalogers/backstage-catalog-info@v1.12.0
  - uses: github://earthly/lunar-lib/catalogers/backstage-catalog-info-monorepo@v1.12.0
    with:
      orgs: acme
      allowed_topics: monorepo

  # 3.3 Classification derived from collected evidence.
  - name: production-detection
    runBash: |-
      JSON="$(lunar component get-json "$LUNAR_COMPONENT_ID")"
      if echo "$JSON" | jq -e '.k8s.deployments[]? | select(.namespace == "prod")' >/dev/null; then
        lunar catalog raw --json ".components.\"$LUNAR_COMPONENT_ID\".tags" '["production"]'
      fi
    hook:
      type: component-cron
      schedule: "0 3 * * *"

collectors:
  # 2.1 Raw catalog entries into the Component JSON.
  - uses: github://earthly/lunar-lib/collectors/backstage@v1.12.0
    on: ["domain:engineering"]

initiatives:
  - name: cataloging
    description: Catalog completeness and correctness
    owner: platform@acme.com
    on: ["domain:engineering"]

policies:
  # 2.2 + 2.3 Structure, identity, and coverage.
  - uses: github://earthly/lunar-lib/policies/backstage@v1.12.0
    name: catalog-structure
    initiative: cataloging
    on: ["domain:engineering"]
    include: [catalog-info-exists, catalog-info-valid, owner-set, lifecycle-set, system-set]
    enforcement: report-pr

  # 3.2 Semantics, once the structure is clean.
  - uses: github://earthly/lunar-lib/policies/backstage@v1.12.0
    name: catalog-semantics
    initiative: cataloging
    on: ["domain:engineering"]
    include: [domain-exists, system-exists, required-tag-patterns]
    with:
      required_tag_patterns: "tier/*,data-classification/*"
    enforcement: score
```

{% endcode %}

## See also

* [Catalogers](/configuration/lunar-config/catalogers) and [cataloger hooks](/configuration/lunar-config/cataloger-hooks) — configuration reference
* [Components](/configuration/lunar-config/components) — naming rules, `paths`, and CI attribution
* [Domains](/configuration/lunar-config/domains) — hierarchy and ownership
* [Catalog JSON](/docs/catalog-json) — the merged structure and its precedence rules
* [Tag Matching with `on`](/configuration/lunar-config/on) — targeting expressions
* [Cataloger Bash SDK](/plugin-sdks/bash-sdk/cataloger) — the `lunar catalog` command
* [AI Skills](/install/skills) — agent skills for writing catalogers, collectors, and policies


# Component JSON

How the Component JSON aggregates SDLC metadata deltas from collectors into a single object that policies evaluate against.

* Type: `JSON`

The Component JSON is a JSON object that contains SDLC metadata associated with a component. The JSON object is formed by collecting metadata deltas via the different collectors.

The Component JSON is meant to represent the point-in-time state of a component. The JSON object is stored in the database and is used to evaluate the health of the component via policies.

The structure of the component JSON is arbitrary. The JSON object acts as an interface layer between the collectors and the policies. It can have any structure, as needed to convey the information used by the policies, or to track the state of a certain metric or characteristic over time for a given component or set of components.

To view the component JSON for a given component, you can use the `lunar component get-json` command:

```bash
lunar component get-json github.com/my-org/my-repo
```

To read more about how to configure the collectors that contribute deltas to the component JSON, see the [collectors](/configuration/lunar-config/collectors) page.

To read more about how to query the component JSONs via the SQL API see the [components view](/sql-api/views/components) page.


# Catalog JSON

Structure and precedence rules for the Catalog JSON, which merges domains and components from catalogers, lunar.yml, and lunar-config.yml.

* Type: `JSON`
* Form:

  ```json
  {
    "domains": {
      "<domain-name>": {
        "description": "<description>",
        "owner": "<owner>",
        "meta": {  // 🚧 Coming Soon
          "<meta-key>": "<meta-value>",
          ...
        }
      },
      ...
    },
    "components": {
      "<component-name>": {
        "owner": "<owner>",
        "domain": "<domain>",
        "branch": "<branch>",
        "description": "<description>",
        "paths": ["<path1>", "<path2>", ...],
        "tags": ["<tag1>", "<tag2>", ...],
        "ciPipelines": ["<ci-pipeline1>", "<ci-pipeline2>", ...],
        "meta": {
          "<meta-key>": "<meta-value>",
          ...
        }
      },
      ...
    }
  }
  ```

The Catalog JSON is a JSON object that contains information about the domains and components.

The Catalog JSON, unlike the Component JSON, has a pre-defined structure. The semantics of the fields are defined in the [domains](/configuration/lunar-config/domains) and [components](/configuration/lunar-config/components) pages. The same fields used to configure domains and components in `lunar-config.yml` are used to define the structure of the Catalog JSON.

The JSON object is formed by collecting information from the catalogers, and then merging that information with any data from `lunar.yml` and `lunar-config.yml`. The precedence in which the information is used is as follows:

1. The information from `lunar-config.yml` in the `domains` and `components` section.
2. The information from `lunar.yml` in each component directory. 🚧 Coming Soon
3. The information from the catalogers, where the **last-defined cataloger takes precedence**.

So, for example, if a cataloger emits a component with the same name as a component defined in `lunar-config.yml`, the fields would be combined, and any fields that exist in both would be overridden by the `lunar-config.yml` values.

If you would like to inspect the Catalog JSON, you can do so by running the following command:

```bash
lunar cataloger get-json
```

If you would like to execute the catalogers in development mode, and see the Catalog JSON that would be generated, you can do so by running the following command (needs to be run in the root of the Lunar configuration repository):

```bash
lunar cataloger dev --output-json
```


# Lunar CLI Reference

Complete reference for every Lunar CLI command, subcommand, flag, and environment variable across collectors, catalogers, policies, and Hub operations.

This document provides a comprehensive reference for all available options and commands in the Lunar CLI.

## Global options

### `--config-dir <config-dir>`, `LUNAR_CONFIG_DIR=<config-dir>`

* Type: `string`
* Optional
* Default: `.`

The path to the directory containing `lunar-config.yml`. This path is relative to the current working directory.

### `--hub-host <hostname>`, `LUNAR_HUB_HOST=<hostname>`

* Type: `string`
* Optional

Override the host name of the Lunar Hub. This setting is inferred from the Lunar config if not specified.

Correct:

* `hub.example.com`
* `myhost`

Incorrect:

* `https://hub.example.com`
* `http://hub.example.com`
* `hub.example.com:8080`
* `myhost:443`

### `--hub-grpc-port <port>`, `LUNAR_HUB_GRPC_PORT=<port>`

* Type: `integer`
* Optional

Override the GRPC port of the Lunar Hub. This setting is inferred from the Lunar config if not specified.

### `--hub-http-port <port>`, `LUNAR_HUB_HTTP_PORT=<port>`

* Type: `integer`
* Optional

Override the HTTP port of the Lunar Hub. This setting is inferred from the Lunar config if not specified.

### `--hub-insecure`, `LUNAR_HUB_INSECURE=true`

* Type: `boolean`
* Optional

If true, use insecure HTTP connections to the Hub server.

### `--no-hub`, `LUNAR_NO_HUB=true`

* Type: `boolean`
* Optional

Skip Hub interactions for dev commands (`lunar collector dev` and `lunar policy dev`). When enabled, the commands will run without connecting to Lunar Hub. Note that `--component-json` is required for `lunar policy dev` when this option is enabled, and `LUNAR_GITHUB_TOKEN` (or `LUNAR_GITLAB_TOKEN`, for components on GitLab) must be set if GitHub or GitLab access is needed.

### `LUNAR_HUB_TOKEN`

* Type: `string`
* Required for commands that interact with the Hub server

The Lunar Hub token to use for authentication.

## Licence Commands

Inspect a Lunar licence JWT and extract artefacts for cluster bootstrap. These commands run **locally** — they verify the licence against a trust list embedded in the `lunar` binary, so they work before a Hub exists (the canonical bootstrap case).

### Shared options

#### `--licence-file <path>`, `LUNAR_LICENCE_FILE=<path>`

* Type: `string`
* Required (or set the env var)

Path to the licence JWT on disk. All `lunar licence` subcommands accept this flag, or read `LUNAR_LICENCE_FILE` if it's unset.

### `lunar licence verify`

* Form:

  ```bash
  lunar licence verify --licence-file <path>
  ```

Verify the licence signature against the trust list embedded in this binary and print a customer-facing summary: tenant, expiry, and whether the licence carries a GHCR image-pull credential. Credentials are never printed — use `lunar licence registry-token` if you need the credential itself for manual `docker login`.

Example output:

```
Licence valid for tenant: acme-corp
Expires:                  2027-12-31 (in 587 days)

GHCR pull credential: configured — run `lunar licence pull-secret` to provision
```

### `lunar licence pull-secret`

* Form:

  ```bash
  lunar licence pull-secret --licence-file <path> --namespace <ns> [--name <name>] [--out <path>]
  ```

Emit a Kubernetes `imagePullSecret` (type `kubernetes.io/dockerconfigjson`) that authenticates the cluster against `ghcr.io` using the credential carried in the licence. Pipe to `kubectl apply -f -`, commit to GitOps, whatever fits.

Errors if the licence does not carry a GHCR pull credential.

#### `--namespace <ns>` | `-n`

* Type: `string`
* Required

Kubernetes namespace for the Secret.

#### `--name <name>`

* Type: `string`
* Optional
* Default: `regcred`

Kubernetes Secret name. The Hub chart's `imagePullSecrets` reference defaults to `regcred`.

#### `--out <path>` | `-o`

* Type: `string`
* Optional

Write the manifest to a file instead of stdout. The file is created with mode `0600` because it contains a sensitive credential.

#### Examples

```bash
# Provision the pull secret in the lunar namespace, applied to the cluster directly.
lunar licence pull-secret \
  --licence-file=path/to/hub-licence.jwt \
  --namespace=lunar | kubectl apply -f -

# Same, but for the snippet pods namespace.
lunar licence pull-secret \
  --licence-file=path/to/hub-licence.jwt \
  --namespace=lunar-scripts | kubectl apply -f -

# Write the manifest to disk for GitOps.
lunar licence pull-secret \
  --licence-file=path/to/hub-licence.jwt \
  --namespace=lunar \
  --out=regcred.yaml
```

### `lunar licence registry-token`

* Form:

  ```bash
  lunar licence registry-token --licence-file <path>
  ```

Print the raw GHCR pull credential from the licence to stdout, suitable for piping into `docker login --password-stdin`. Errors if the licence does not carry one.

Example:

```bash
lunar licence registry-token --licence-file=path/to/hub-licence.jwt | \
  docker login ghcr.io -u earthly-bot --password-stdin
```

## Config Commands

### `lunar config schema`

* Form:

  ```bash
  lunar config schema
  ```

Print the JSON Schema (draft-07 format) for `lunar-config.yml` to stdout, generated from the same manifest definition the Hub uses. Redirect it into your config repo for editor autocomplete and inline structural feedback:

```bash
lunar config schema > lunar-config.schema.json
```

The schema is a structural aid only (it can't resolve `uses:` plugins). For authoritative pre-merge validation, use [`lunar hub pull --dry-run`](#lunar-hub-pull). See [Validating your config](/configuration/lunar-config/validation).

### `lunar hub pull`

* Form:

  ```bash
  lunar hub pull [--dry-run] [--rerun-code-collectors|-l] [--include-pr-commits] [--pr-max-age-days <days>] [--rerun-catalogers|-t] <repo>
  ```

The `lunar hub pull` command is used to instruct Lunar Hub to pull the latest configuration from a given repository.

For GitHub Actions workflows, the [`sync-config` action](/install/lunar-hub/self-hosted/sync-config) is a thin wrapper around this command and exposes the same flags as inputs.

{% hint style="warning" %}
Using `--rerun-code-collectors` or `--rerun-catalogers` can trigger a large amount of work in Lunar Hub, especially across many components or PRs. Expect a backlog and elevated load while the reruns process.
{% endhint %}

#### `<repo>`

* Type: `string`
* Form: `github://<owner>/<repo>[/<config-path>]@<branch-or-sha>` or `gitlab://<host>/<namespace>/<project>[/-/<config-path>]@<branch-or-sha>`
* Required

The repository to pull configuration from. This should be the main repository containing your lunar configuration files.

**Examples:**

* `github://acme-corp/lunar@main`
* `github://acme-corp/lunar@de4adbeef`
* `gitlab://gitlab.com/acme-corp/lunar@main`
* `gitlab://gitlab.example.com/acme-corp/platform/lunar@main`

The GitLab form requires an explicit host, including for `gitlab.com`, and the namespace may contain subgroups.

The optional `<config-path>` selects which configuration file to load, so one repository can hold several — one per Hub or per environment — and a monorepo can keep its configuration somewhere other than the root. It may name the file itself (`github://acme-corp/lunar/lunar-config.dev.yml@main`) or a directory holding a `lunar-config.yml` (`github://acme-corp/lunar/configs/dev@main`). Omitted, the entry point is `lunar-config.yml` at the repository root.

A local filesystem path is also accepted, and may likewise name either a directory or the configuration file inside it — useful with `--dry-run`.

#### `--dry-run`

* Type: `boolean`
* Optional

Validate the configuration and exit **without** applying it. The dry run performs the Hub's own load-and-validate steps against the same code — clone the config, load the entry point + its fragments, resolve every `uses:` plugin, validate the whole manifest, and resolve each unpinned component's default branch — then stops before persisting. It does not install per-snippet dependencies, so that one step could still fail on a real pull; see [Validating your config](/configuration/lunar-config/validation) for the details.

It needs **no Hub connection**, which makes it suitable as a pre-merge CI check. It does need GitHub or GitLab access to resolve `uses:` plugins (set `LUNAR_GITHUB_TOKEN` or `LUNAR_GITLAB_TOKEN`, or configure the Hub as an auth source). Exits non-zero on any validation error. See [Validating your config](/configuration/lunar-config/validation) for a CI example.

#### `--rerun-code-collectors` | `-l`

* Type: `boolean`
* Optional

Rerun affected code collectors after applying the configuration.

#### `--include-pr-commits`

* Type: `boolean`
* Optional

Include PR commits when rerunning code collectors.

#### `--pr-max-age-days <days>`

* Type: `integer`
* Optional
* Default: `5`

Ignore PR commits older than this maximum number of days.

#### `--rerun-catalogers` | `-t`

* Type: `boolean`
* Optional
* Default: `false`

Rerun global catalogers after pulling the manifest. Catalogers are skipped by default — pulling a manifest no longer triggers them automatically. Per-component catalogers (`component-repo`, `component-cron`) are unaffected and continue to run on their own hooks.

### `lunar hub run-code-collectors`

{% hint style="warning" %}
**Deprecated — use** [**`lunar collector run`**](#lunar-collector-run)**.** That command covers cron collectors as well, and can scope a rerun to one component, PR, or commit. `lunar collector run --only-code` is the closest equivalent.

This command still works and is not scheduled for removal. It remains the only collector trigger available against a Lunar Hub older than the release that added `lunar collector run`, and it is currently the only way to sweep primary-branch commits *without* also covering recent PR commits.
{% endhint %}

* Form:

  ```bash
  lunar hub run-code-collectors [--pr-max-age-days <days>] [--include-pr-commits]
  ```

The `lunar hub run-code-collectors` command instructs Lunar Hub to rerun code collectors.

{% hint style="warning" %}
This command can trigger a large amount of work in Lunar Hub. It reruns code collectors across all components — and across recent PR commits when `--include-pr-commits` is set — which can produce a significant backlog and elevated load.
{% endhint %}

#### `--pr-max-age-days <days>`

* Type: `integer`
* Optional
* Default: `5`

Ignore PR commits older than this maximum number of days.

#### `--include-pr-commits`

* Type: `boolean`
* Optional

Include PR commits when running code collectors.

### `lunar hub get-logs`

* Form:

  ```bash
  lunar hub get-logs [--namespace <namespace>] [--output <format>] [--tail <lines>]
  ```

The `lunar hub get-logs` command retrieves logs from Lunar Hub.

#### `--namespace <namespace>` | `-n`

* Type: `string`
* Optional

The Kubernetes namespace to retrieve logs from.

#### `--output <format>` | `-o`

* Type: `string`
* Optional

Output format for the logs.

#### `--tail <lines>`

* Type: `integer`
* Optional

Number of lines to show from the end of the logs.

## Domain Commands

### `lunar domain ls`

{% hint style="info" %}
**Coming Soon** — This feature is not yet available.
{% endhint %}

* Form:

  ```bash
  lunar domain ls
  ```

The `lunar domain ls` command is used to list all domains.

## Component Commands

### `lunar component ls`

{% hint style="info" %}
**Coming Soon** — This feature is not yet available.
{% endhint %}

* Form:

  ```bash
  lunar component ls
  ```

The `lunar component ls` command is used to list all components.

### `lunar component get-json`

* Form:

  ```bash
  lunar component get-json [--git-sha <git-sha>] [--pr <pr-number>] [component-name]
  ```

The `lunar component get-json` command is used to retrieve the component JSON for a specified component.

#### `[component-name]`

* Type: `string`

The name of the component to retrieve the JSON for. If not provided, falls back to the `LUNAR_COMPONENT_ID` environment variable.

#### `--git-sha <git-sha>`

* Type: `string`
* Optional

The specific git SHA to retrieve the component JSON for. If not specified, the latest component JSON will be retrieved.

#### `--pr <pr-number>`

* Type: `integer`
* Optional

The PR number to retrieve the component JSON for. If not specified, and no git SHA is provided, the component JSON for the primary branch will be retrieved. Combined with `--git-sha`, it narrows the lookup to that commit as seen in that PR; a `--git-sha` on its own resolves the commit whether it was reached through the primary branch or a PR.

#### `--pretty` | `-p`

* Type: `boolean`
* Optional

Pretty-print the JSON output.

Example:

```bash
lunar component get-json github.com/my-org/my-repo
```

## Cataloger Commands

`lunar cataloger` manages catalogers from outside. This is distinct from `lunar catalog`, which is the SDK command catalogers use *inside* their own scripts to emit catalog entries.

### `lunar cataloger get-json`

* Form:

  ```bash
  lunar cataloger get-json [--cataloger <name>] [--component <name>] [--ts <timestamp>] [--pretty]
  ```

The `lunar cataloger get-json` command retrieves the catalog JSON from Lunar Hub. By default it returns the latest merged catalog snapshot (the same JSON that all catalogers contribute to). Use `--cataloger` to fetch a single cataloger's most recent contribution (its delta), `--component` to scope a per-component cataloger to one component, and `--ts` to fetch a historical snapshot.

#### `--cataloger <name>`

* Type: `string`
* Optional

Name of the cataloger whose delta should be returned. If omitted, the merged catalog snapshot is returned. The cataloger is resolved by exact name in the current manifest; an unknown name returns an error.

Whether `--component` is allowed depends on the cataloger's hooks:

* A **global** cataloger (only `cron` / `repo` hooks) returns a single delta. Passing `--component` is rejected.
* A **per-component** cataloger (any `component-cron` / `component-repo` hook) stores one delta per component. `--component` is required; omitting it is rejected with a "per-component scoped" error.

#### `--component <name>`

* Type: `string`
* Optional

Component to scope the lookup to (e.g. `github.com/my-org/my-repo`). Only valid together with `--cataloger`, and only for per-component catalogers — required for those, rejected for purely global ones. Returns the delta that cataloger emitted for the named component.

#### `--ts <timestamp>`

* Type: `string`
* Optional

Return the row whose `created_at` is at or immediately before this timestamp. If omitted, the latest row is returned. The command exits with an error if no row exists at or before the timestamp.

Accepted formats (parsed in this order):

* Date only: `2026-05-01` (interpreted as `2026-05-01T00:00:00Z`)
* Date + time without timezone: `2026-05-01T12:34:56` (interpreted as UTC)
* Full RFC3339: `2026-05-01T12:34:56Z` or `2026-05-01T12:34:56-07:00`

#### `--pretty` | `-p`

* Type: `boolean`
* Optional

Pretty-print the JSON output.

Examples:

```bash
# Latest merged catalog snapshot
lunar cataloger get-json --pretty
# A global cataloger's most recent delta
lunar cataloger get-json --cataloger sbom --pretty
# A per-component cataloger's delta for one component, as of a date
lunar cataloger get-json --cataloger sbom --component github.com/my-org/my-repo --ts 2026-05-01 --pretty
```

### `lunar cataloger run`

* Form:

  ```bash
  lunar cataloger run [--cataloger <name>] [--component <name>] [--output-json]
  ```

The `lunar cataloger run` command triggers cataloger execution in Lunar Hub. The CLI enqueues the matching catalogers, then polls every 10 seconds and prints `queued / success / error` counters until every job is in a terminal state. With no flags, it runs every cataloger in the current manifest. Use `--cataloger` and/or `--component` to narrow the scope.

#### `--cataloger <name>`

* Type: `string`
* Optional

Run only the named cataloger (exact match or dotted-plugin prefix like `myplugin`). If omitted, every cataloger in the manifest participates. When the named cataloger has only global hooks, combining it with `--component` is rejected. When the named cataloger has per-component hooks and `--component` is omitted, it fans out to every component in the manifest.

#### `--component <name>`

* Type: `string`
* Optional

Scope per-component hooks (`component-repo` / `component-cron`) to this single component. When omitted, those hooks fan out across every component in the manifest. Global hooks (`cron` / `repo`) are unaffected.

#### `--output-json`

* Type: `boolean`
* Optional

After the polling loop completes, fetch and print the merged catalog JSON. If no catalog row exists yet (fresh manifest with no completed catalogers), prints `(no catalog yet)` to stderr and exits with the same code as if `--output-json` were not set.

Failures: for any job that ends in the `error` bucket, the CLI prints a one-line summary including a deep link to the Grafana run-details dashboard (if Hub is configured with `HUB_GRAFANA_URL_BASE`). When no snippet\_run is found for a job (rare — worker crashed before recording the run), the link is replaced with `(no run record; check hub logs)`.

Example:

```bash
lunar cataloger run --cataloger sbom --component github.com/my-org/my-repo --output-json
```

### `lunar cataloger dev`

* Form:

  ```bash
  lunar cataloger dev [cataloger-name] \
    [--component <name> | --component-dir <path>] \
    [--config <repo>] \
    [--git-sha <sha>] \
    [--script <path>] [--script-lang <bash|python|node>] \
    [--use-system-runtime] [--no-cache] [--verbose] \
    [--secrets <k=v,k=v>] \
    [--output-json]
  ```

{% hint style="warning" %}
Catalogers can be highly environment-dependent. Be mindful of "works on my machine" types of issues.
{% endhint %}

The `lunar cataloger dev` command runs catalogers locally on the user's machine without applying changes to the catalog. It mirrors `lunar collector dev` for catalogers: the cataloger snippet is fetched from the configured manifest, executed under the configured runtime (Python / Node / Bash, or Docker when the snippet has an image), and its output is printed. No data is written to Lunar Hub.

#### `[cataloger-name]`

* Type: `string`
* Optional

Cataloger name (or dotted plugin name like `myplugin.sync`) to execute. If omitted, every cataloger applicable to the resolved scope is run.

#### `--component <name>`

* Type: `string`
* Optional

Look the component up in the resolved manifest. Required when running a per-component cataloger. Mutually exclusive with `--component-dir`.

#### `--component-dir <path>`

* Type: `string`
* Optional

Treat the given local directory as the component checkout (no clone). Mutually exclusive with `--component` and `--config`.

#### `--config <repo>`

* Type: `string`
* Optional

Remote configuration repository to load the manifest from (e.g. `github.com/org/repo` or `github://org/repo@branch`). Mutually exclusive with `--component-dir`.

#### `--git-sha <sha>`

* Type: `string`
* Optional

Pin the component checkout to this SHA on the component's primary branch. Only meaningful for per-component catalogers. Defaults to the tip of the primary branch.

{% hint style="info" %}
Unlike `lunar collector dev`, there is no `--pr` flag — catalogers run on pushes to main, not on PR commits.
{% endhint %}

#### `--script <path>`

* Type: `string`
* Optional

Override the cataloger's `main` script with a local file path. Combine with `--script-lang` to control the runtime used.

#### `--script-lang <language>`

* Type: `string`
* Optional
* Default: `bash`

Language for `--script` (`bash` / `python` / `node`).

#### `--use-system-runtime`

* Type: `boolean`
* Optional

Use the host's system Python / Node / Bash instead of the bundled Lunar runtimes. Defaults to `true` on non-Linux-amd64 hosts.

#### `--no-cache`

* Type: `boolean`
* Optional

Delete cacheable temporary files before running. Use this to force a clean run when troubleshooting.

#### `--verbose`

* Type: `boolean`
* Optional

Stream the cataloger's engine stdout / stderr to the terminal as it runs.

#### `--secrets <k=v,k=v>`

* Type: `string`
* Optional

Supplemental secrets injected into the cataloger's environment, parsed as comma-separated `key=value` pairs.

#### `--output-json`

* Type: `boolean`
* Optional

Print the merged Catalog JSON the user would see if these catalogers ran in Hub (manifest base + cataloger deltas + manifest overrides), in the same shape as [Catalog JSON](/docs/catalog-json). Without this flag, each cataloger's raw emitted JSON is printed under a per-cataloger header.

Example:

```bash
lunar cataloger dev sbom --component github.com/my-org/my-repo --verbose
```

## Collector Commands

### `lunar collector run`

* Form:

  ```bash
  lunar collector run [--output-json] [--pr <pr-number>] \
    [--git-sha <git-sha>] [--only-code] [--only-cron] \
    [--collector <collector-name>] [--pr-max-age-days <days>] <component-name>
  ```

The `lunar collector run` command is used to rerun code and cron collectors for a given component. This command triggers execution in the cloud via Lunar Hub.

Collectors are dispatched asynchronously, so the command reports what it triggered rather than waiting for results. Read the outcome with [`lunar component get-json`](#lunar-component-get-json) once the collectors have run.

{% hint style="info" %}
**Naming a collector that isn't in the current manifest is an error.** Lunar Hub checks the names given to `--collector` before dispatching anything, so a typo fails the command instead of quietly running nothing.
{% endhint %}

#### `<component-name>`

* Type: `string`
* Optional

The name of the component to rerun collectors for. Omit it to cover every component, across both code and cron collectors. The code leg also covers recent PR commits, bounded by `--pr-max-age-days`.

{% hint style="warning" %}
**Omitting the component name can trigger a large amount of work in Lunar Hub.** It reruns collectors across every component, which can produce a significant backlog and elevated load.

Naming a component is the only way to bound both legs. `--pr-max-age-days` bounds the code leg alone: the cron leg fans out over every open pull request of every component it matches, capped per component rather than by age.
{% endhint %}

#### `--pr <pr-number>`

* Type: `integer`
* Optional

The PR number to rerun collectors for. If not specified, collectors will be run for the component's primary branch. Requires a component name, since the PR is resolved against that component's repository.

#### `--git-sha <git-sha>`

* Type: `string`
* Optional

The specific git SHA to rerun collectors for. If specified, this takes precedence over `--pr`. Requires a component name, and the commit must be one Lunar Hub has already ingested for that component's repository.

#### `--only-code`

Run only code collectors.

#### `--only-cron`

Run only cron collectors.

#### `--collector <collector-name>`

* Type: `string`
* Optional
* Repeatable

Run only the specified collector. This flag can be repeated to run multiple specific collectors. A name matches a collector exactly, or matches every sub-collector of a plugin: `--collector trivy` selects `trivy.auto` and `trivy.rescan`.

#### `--pr-max-age-days <days>`

* Type: `integer`
* Optional
* Default: `5`

Ignore PR commits older than this maximum number of days. Only consulted for the fleet-wide **code** sweep, which covers recent PR commits as well as each repository's primary branch. Cron collectors ignore it, so it has no effect at all under `--only-cron`.

#### `--output-json`

Output the results in JSON format: one object per triggered run, carrying `component`, `collector`, `source` (`code` or `cron`), `repo_uri`, `commit_sha`, and `pr`. A run that Lunar Hub deduplicated onto an identical run already in flight is reported with `already_pending: true` rather than omitted, so an empty array means nothing matched.

Example:

```bash
# Rerun all collectors for all components for all PRs, limiting to 1 day old PRs
lunar collector run --pr-max-age-days 1
# Rerun collectors for a component (primary branch)
lunar collector run github.com/my-org/my-repo
# Rerun collectors for a specific PR
lunar collector run --pr 123 github.com/my-org/my-repo
# Rerun collectors for a specific git SHA
lunar collector run --git-sha abc123 github.com/my-org/my-repo
# Run only code collectors
lunar collector run --only-code github.com/my-org/my-repo
# Run a cron collector now, without waiting for its next tick
lunar collector run --only-cron --collector pagerduty.oncall github.com/my-org/my-repo
# Run only a specific collector
lunar collector run --collector collector-name github.com/my-org/my-repo
```

### `lunar collector dev`

* Name Form:

  ```bash
  lunar collector dev [--pr <pr-number>] [--git-sha <git-sha>] \
    [--component <component-name> | --component-dir <path>] \
    [--fake-ci-cmd <bash-command>] \
    <collector-name>
  ```
* Script Form:

  ```bash
  lunar collector dev \
    [--pr <pr-number>] [--git-sha <git-sha>] \
    [--component <component-name> | --component-dir <path>] \
    [--fake-ci-cmd <bash-command>] \
    --script <path-to-collector-script>
  ```

{% hint style="warning" %}
Collectors can be highly environment-dependent. Be mindful of "works on my machine" types of issues.
{% endhint %}

The `lunar collector dev` command is used to run a collector for a given component in development mode without applying changes. This command executes locally on the user's machine and outputs the resulting component JSON.

#### `<collector-name>`

* Type: `string`
* Required in Name Form

#### `--script <path-to-collector-script>`

* Type: `string`
* Required in Script Form

#### `--component <component-name>`

* Type: `string`

The name of the component to run collectors for. If not provided, falls back to the `LUNAR_COMPONENT_ID` environment variable. Mutually exclusive with `--component-dir`.

#### `--component-dir <path>`

* Type: `string`

Local directory containing the component to run collectors for. The directory must be a git repository. The component name is derived from the git remote URL. Mutually exclusive with `--component`.

#### `--pr <pr-number>`

* Type: `integer`
* Optional

The PR number to run collectors for. If not specified, collectors will be run for the component's primary branch.

{% hint style="warning" %}
`--pr` requires a GitHub connection and is not available for components on GitLab — the command exits with an error. Use `--git-sha`, or the component's branch, instead.
{% endhint %}

#### `--git-sha <git-sha>`

* Type: `string`
* Optional

The specific git SHA to run collectors for. If specified, this takes precedence over `--pr`.

#### `<collector-name>`

* Type: `string`
* Optional

The name of the collector to run. If not specified, all collectors will be run.

The path to a bash collector script file to run in development mode.

#### `--fake-ci-cmd <bash-command>`

* Type: `string`
* Optional

A command that the CI would have executed, that would cause lunar instrumentation to trigger an event for. This command is not actually executed, it is merely used to test collector triggering logic (e.g. would the collector trigger regex match the command line). This option is used for testing CI collectors locally without requiring an actual CI pipeline execution.

#### `--config <repo>`

* Type: `string`
* Optional

Remote config repository to use.

#### `--use-system-runtime`

* Type: `boolean`
* Optional

Use the system runtime instead of a containerized environment.

#### `--no-cache`

* Type: `boolean`
* Optional

Disable caching.

#### `--verbose`

* Type: `boolean`
* Optional

Enable verbose output.

#### `--merge`

* Type: `boolean`
* Optional

Merge the collected data into the existing component JSON.

#### `--secrets`

* Type: `boolean`
* Optional

Fetch and inject secrets into the collector execution environment.

#### Examples

```bash
# Run collectors in development mode for a component (primary branch)
lunar collector dev --component github.com/my-org/my-repo
# Run collectors in development mode for a specific PR
lunar collector dev --component github.com/my-org/my-repo --pr 123
# Run a specific collector
lunar collector dev --component github.com/my-org/my-repo collector-name
# Run a specific collector script file
lunar collector dev --component github.com/my-org/my-repo --script ./path/to/collector.sh
# Test a CI collector with a fake CI command
lunar collector dev --component github.com/my-org/my-repo --fake-ci-cmd "npm test" ci-collector-name
# Run collectors against a local directory
lunar collector dev --component-dir ./my-local-repo collector-name
# Run collectors against a local directory with a specific git SHA
lunar collector dev --component-dir ./my-local-repo --git-sha abc123 collector-name
```

#### Credentials

What credentials `lunar collector dev` needs depends on how you name the component:

* **`--component-dir <path>`** runs against a checkout you already have. It performs only local git operations (no clone), so it needs **no Hub and no GitHub or GitLab token**. This is the way to smoke-test a collector against a local component with no credentials — pair it with `--no-hub` to skip Hub entirely, and `--script` to run a single script without resolving the manifest.
* **`--component <name>`** must resolve the component's default branch and clone it, so it needs **either a configured Hub or a token** — `LUNAR_GITHUB_TOKEN`, or `LUNAR_GITLAB_TOKEN` for a component on GitLab. Without one, the command errors and points you at `--component-dir`.

## Policy Commands

### `lunar policy ls`

{% hint style="info" %}
**Coming Soon** — This feature is not yet available.
{% endhint %}

* Form:

  ```bash
  lunar policy ls
  ```

The `lunar policy ls` command is used to list all policies.

### `lunar policy check ls`

{% hint style="info" %}
**Coming Soon** — This feature is not yet available.
{% endhint %}

* Form:

  ```bash
  lunar policy check ls
  ```

The `lunar policy check ls` command is used to list all checks.

### `lunar policy run`

{% hint style="info" %}
**Coming Soon** — This feature is not yet available.
{% endhint %}

* Form:

  ```bash
  lunar policy run [--output-json] [--pr <pr-number>] \
    [--git-sha <git-sha>] [--policy <policy-name>] \
    [--initiative <initiative-name>] <component-name>
  ```

The `lunar policy run` command is used to rerun all policies for a given component. This command triggers execution in the cloud via Lunar Hub.

#### `<component-name>`

* Type: `string`

The name of the component to rerun policies for.

#### `--pr <pr-number>`

* Type: `integer`
* Optional

The PR number to rerun policies for. If not specified, policies will be run for the component's primary branch.

#### `--git-sha <git-sha>`

* Type: `string`
* Optional

The specific git SHA to rerun policies for. If specified, this takes precedence over `--pr`.

#### `--policy <policy-name>`

* Type: `string`
* Optional
* Repeatable

Run only the specified policy. This flag can be repeated to run multiple specific policies.

#### `--initiative <initiative-name>`

* Type: `string`
* Optional
* Repeatable

Run only policies under the specified initiative. This flag can be repeated to run policies under multiple initiatives.

#### `--output-json`

Output the results in JSON format.

Example:

```bash
# Rerun policies for a component (primary branch)
lunar policy run github.com/my-org/my-repo
# Rerun policies for a specific PR
lunar policy run --pr 123 github.com/my-org/my-repo
# Rerun policies for a specific git SHA
lunar policy run --git-sha abc123 github.com/my-org/my-repo
# Run only a specific policy
lunar policy run --policy policy-name github.com/my-org/my-repo
# Run policies under a specific initiative
lunar policy run --initiative initiative-name github.com/my-org/my-repo
```

### `lunar policy dev`

* Name Form:

  ```bash
  lunar policy dev [--component <component-name>] [--component-json <path-to-json-or-stdin>] \
    [--pr <pr-number>] [--git-sha <git-sha>] \
    <policy-name>
  ```
* Script Form:

  ```bash
  lunar policy dev [--component <component-name>] [--component-json <path-to-json-or-stdin>] \
    [--pr <pr-number>] [--git-sha <git-sha>] \
    --script <path-to-policy-script>
  ```

{% hint style="warning" %}
Policies can be highly environment-dependent. Be mindful of "works on my machine" types of issues.
{% endhint %}

The `lunar policy dev` command is used to run a policy against a component for local testing purposes. This command executes locally on the user's machine and outputs the check results in JSON format.

#### `<policy-name>`

* Type: `string`
* Required in Name Form

#### `--script <path-to-policy-script>`

* Type: `string`
* Required in Script Form

#### `--component <component-name>`

* Type: `string`

The name of the component to run the policy against. If not provided, falls back to the `LUNAR_COMPONENT_ID` environment variable.

#### `--component-json <path-to-json-or-stdin>`

* Type: `string`

The path to the component JSON file or `-` to read from stdin.

#### `--pr <pr-number>`

* Type: `integer`
* Optional

The PR number to run the policy against. If not specified, the policy will be run against the component's primary branch.

#### `--git-sha <git-sha>`

* Type: `string`
* Optional

The specific git SHA to run the policy against. If specified, this takes precedence over `--pr`.

#### `--with <args>`

* Type: `string`
* Optional

Arguments passed to the policy script.

#### `--script-lang <language>`

* Type: `string`
* Optional
* Default: `python`

The script programming language.

#### `--output <format>`

* Type: `string`
* Optional
* Values: `json`, `list`

Output format for the policy results.

#### `--config <repo>`

* Type: `string`
* Optional

Remote config repository to use.

#### `--use-system-runtime`

* Type: `boolean`
* Optional

Use the system runtime instead of a containerized environment.

#### `--no-cache`

* Type: `boolean`
* Optional

Disable caching.

#### `--verbose`

* Type: `boolean`
* Optional

Enable verbose output.

#### `--secrets`

* Type: `boolean`
* Optional

Fetch and inject secrets into the policy execution environment.

#### Example

```bash
# Run policy with component JSON from file
lunar policy dev --component-json path/to/component.json --script ./path/to/policy.py
# Run policy by specifying component directly
lunar policy dev --component github.com/my-org/my-repo --script ./path/to/policy.py
# Run policy with component JSON from stdin
lunar component get-json --git-sha ... github.com/my-org/my-repo | \
  lunar policy dev --component-json - --script ./path/to/policy.py
# Run specific policy from config
lunar policy dev --component github.com/my-org/my-repo my-policy
# Run policy for a specific PR
lunar policy dev --component github.com/my-org/my-repo --pr 123 --script ./path/to/policy.py
```

### `lunar policy ok-release`

* Form:

  ```bash
  lunar policy ok-release <component> <git_sha>
  lunar policy ok-release  # uses LUNAR_COMPONENT_ID and GITHUB_SHA
  ```

The `lunar policy ok-release` command is used to check if a component at a specific git SHA passes its release policies.

Either both positional arguments must be provided, or neither. When no arguments are given, both `LUNAR_COMPONENT_ID` and `GITHUB_SHA` environment variables must be set — outside GitHub Actions, including in GitLab CI, pass the component and SHA as arguments instead.

The verdict block this prints can be replaced with your own wording through [`customization.ok_release_template`](/configuration/lunar-config/customization#ok_release_template). The exit code is unaffected.

#### `<component>`

* Type: `string`

The name of the component to check. Falls back to the `LUNAR_COMPONENT_ID` environment variable when no arguments are given.

#### `<git_sha>`

* Type: `string`

The git SHA to check. Falls back to the `GITHUB_SHA` environment variable when no arguments are given. Unlike `bypass-release` / `bypass-pr`, this gate does not read `CI_COMMIT_SHA`.

#### `--poll-interval <duration>`

* Type: `duration`
* Optional
* Default: `10s`

How often to poll for results.

#### `--timeout <duration>`

* Type: `duration`
* Optional
* Default: `10m`

Maximum time to wait for results before timing out.

#### `--workflow-id <id>`

* Type: `string`
* Optional

The CI workflow ID to associate with the check. Auto-detected from `GITHUB_RUN_ID`; it has no equivalent on GitLab, where Lunar does not record CI runs.

#### `--pr <pr-number>`

* Type: `integer`
* Optional

The PR number to check policies for. Auto-detected from `GITHUB_REF` when running in GitHub Actions — in any other CI, including GitLab, pass it explicitly. On GitLab, use the merge request's number.

#### `--fail-open[=<mode>]`

* Type: `string`
* Optional
* Default: unset (the gate fails when it cannot get a verdict)
* Values: `unreachable` (the default when the flag is given without a value), `timeout`, `both`

Exit `0` instead of erroring when Lunar Hub could not produce a verdict, so an outage never stalls a deploy or a merge. Must be written as `--fail-open=<mode>`; a bare `--fail-open` means `unreachable`.

Fail-open trips on *unavailability*, never on a *verdict* — a component that genuinely fails a blocking policy still exits `1` under every mode.

| Mode          | Unblocks on                                                                                                                                                                                        |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unreachable` | No verdict could be obtained because Lunar Hub was not serving: unreachable from the first call, contact lost mid-poll, rejecting under load, or never answering a single poll before `--timeout`. |
| `timeout`     | Lunar Hub was answering — reporting the evaluation as still running — but never finished it before `--timeout`.                                                                                    |
| `both`        | Either of the above.                                                                                                                                                                               |

`timeout` is deliberately opt-in. A timeout while Lunar Hub is up and evaluating is a verdict still in progress, not an outage, so unblocking on it can let a slow-but-genuinely-blocking evaluation through — especially with a short `--timeout`.

Authentication, authorization and unknown-component errors are **always fatal**, under every mode.

Every fail-open event is logged to standard error.

### `lunar policy ok-pr`

* Form:

  ```bash
  lunar policy ok-pr <component> <git_sha>
  lunar policy ok-pr  # uses LUNAR_COMPONENT_ID and GITHUB_SHA
  ```

The `lunar policy ok-pr` command is used to check if a component at a specific git SHA passes its PR policies.

Either both positional arguments must be provided, or neither. When no arguments are given, both `LUNAR_COMPONENT_ID` and `GITHUB_SHA` environment variables must be set — outside GitHub Actions, including in GitLab CI, pass the component and SHA as arguments instead.

#### `<component>`

* Type: `string`

The name of the component to check. Falls back to the `LUNAR_COMPONENT_ID` environment variable when no arguments are given.

#### `<git_sha>`

* Type: `string`

The git SHA to check. Falls back to the `GITHUB_SHA` environment variable when no arguments are given. Unlike `bypass-release` / `bypass-pr`, this gate does not read `CI_COMMIT_SHA`.

#### `--poll-interval <duration>`

* Type: `duration`
* Optional
* Default: `10s`

How often to poll for results.

#### `--timeout <duration>`

* Type: `duration`
* Optional
* Default: `10m`

Maximum time to wait for results before timing out.

#### `--workflow-id <id>`

* Type: `string`
* Optional

The CI workflow ID to associate with the check. Auto-detected from `GITHUB_RUN_ID`; it has no equivalent on GitLab, where Lunar does not record CI runs.

#### `--pr <pr-number>`

* Type: `integer`
* Optional

The PR number to check policies for. Auto-detected from `GITHUB_REF` when running in GitHub Actions — in any other CI, including GitLab, pass it explicitly. On GitLab, use the merge request's number.

#### `--fail-open[=<mode>]`

* Type: `string`
* Optional
* Default: unset (the gate fails when it cannot get a verdict)
* Values: `unreachable` (the default when the flag is given without a value), `timeout`, `both`

Exit `0` instead of erroring when Lunar Hub could not produce a verdict, so an outage never stalls a deploy or a merge. Must be written as `--fail-open=<mode>`; a bare `--fail-open` means `unreachable`.

Fail-open trips on *unavailability*, never on a *verdict* — a component that genuinely fails a blocking policy still exits `1` under every mode.

| Mode          | Unblocks on                                                                                                                                                                                        |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unreachable` | No verdict could be obtained because Lunar Hub was not serving: unreachable from the first call, contact lost mid-poll, rejecting under load, or never answering a single poll before `--timeout`. |
| `timeout`     | Lunar Hub was answering — reporting the evaluation as still running — but never finished it before `--timeout`.                                                                                    |
| `both`        | Either of the above.                                                                                                                                                                               |

`timeout` is deliberately opt-in. A timeout while Lunar Hub is up and evaluating is a verdict still in progress, not an outage, so unblocking on it can let a slow-but-genuinely-blocking evaluation through — especially with a short `--timeout`.

Authentication, authorization and unknown-component errors are **always fatal**, under every mode. Otherwise an expired token, or one typo in a shared pipeline template, would silently turn every gate into a no-op that never goes red.

Every fail-open event is logged to standard error.

### Bypassing a block

`lunar policy ok-release` and `lunar policy ok-pr` exit `1` when a blocking policy fails. A *bypass* overrides that verdict for a bounded window, so a known failure can be shipped past without disabling the policy for everyone. A pull or merge request can also be bypassed from its own thread with a [`/lunar bypass` comment](/docs/pr-comments); that override is pinned to a commit rather than time-bound, but lands on the same ledger as the commands below.

| Command                       | Purpose                                       |
| ----------------------------- | --------------------------------------------- |
| `lunar policy bypass-release` | Override the release gate for a component     |
| `lunar policy bypass-pr`      | Override the PR/MR merge gate for a component |
| `lunar policy bypass-ls`      | List a component's bypasses                   |
| `lunar policy bypass-rm`      | Revoke a bypass early                         |

Three properties hold for every bypass **these commands create**. The first two do not hold for a comment-driven one, which is bound to a commit rather than a clock and carries a role the Git platform verified.

* **It always expires.** There is no way to create one that lasts forever. Omitting `--for` uses the configured [`bypass.max_duration`](/configuration/lunar-config#bypass), never infinity, because a bypass that never expires is a silently disabled policy.
* **It is audited, not authorized.** The actor is recorded but **not verified**: these commands are trusted-by-token, so anyone holding a Lunar Hub token can run them.
* **It never goes silently green.** A gate cleared by a bypass still prints every check that was suppressed, who authorized it, and until when:

  ```
  🔓 sbom.no-critical-vulns bypassed by alice until 2026-08-17T09:00:00Z: waiting on upstream patch
  	* 2 critical vulnerabilities found
  ```

  A commit-bound bypass reads `while its commit stands` in place of `until <timestamp>`. A partially bypassed gate still fails: checks the bypass does not cover keep blocking.

Both the bypasses and the individual checks they suppressed are queryable over the SQL API, through the [`bypasses`](/sql-api/views/bypasses) and [`bypassed_checks`](/sql-api/views/bypassed-checks) views.

#### Scope

A bypass covers one component and one gate, optionally narrowed by SHA, PR number, and policy. **Any narrowing dimension left unset matches every value of it** — that is what makes a bypass component-wide.

* **The gate is not transitive.** A policy that blocks both PRs and releases fails in each gate separately and needs a bypass in each. Clearing a PR merge never silently clears a deploy.
* **`--policy` names either a whole plugin (`sbom`) or a single check (`sbom.no-critical-vulns`).**
* **A component-wide bypass has to be narrowed on purpose.** With no `--sha`, `--pr` or `--policy` it masks every failing blocking check for the gate, including policies added to the configuration *after* it was created. Lunar Hub rejects that blast radius unless it is narrowed, or given a window chosen rather than inherited:

  ```
  a component-wide bypass must narrow its scope: set at least one of sha, pr, policy, or an explicit duration
  ```

In CI, when neither `--sha` nor `--pr` is given, the commit is inferred from `GITHUB_SHA` or `CI_COMMIT_SHA` and the bypass is scoped to it. A SHA-scoped bypass dies on the next push, which is a tighter bound than any clock. Outside CI there is no SHA to infer, so the bypass stays component-wide and the rule above applies.

When several active bypasses cover the same check, the first match wins. Since each one is a deliberate override in its own right, which one wins affects only the actor the output credits, not what is masked.

#### Durations

`--for`, and [`bypass.max_duration`](/configuration/lunar-config#bypass) that caps it, accept Go's duration grammar extended with `d` (24 hours) and `w` (7 days) units. The units may be mixed: `36h`, `3d`, `2w`, `1w2d`, `1d12h30m`.

A `--for` longer than `bypass.max_duration` is **rejected, not shortened**. Silently clamping it would let you believe a two-week waiver is in place when it expires in two days.

### `lunar policy bypass-release`

* Form:

  ```bash
  lunar policy bypass-release <component> --reason "<why>" [--for <duration>]
  lunar policy bypass-release --reason "<why>"  # uses LUNAR_COMPONENT_ID
  ```

Records a time-bound override of a component's **release** block, so `lunar policy ok-release` passes for as long as it lasts.

Creating or revoking one also refreshes what Lunar reports for the component's default-branch head, re-posting the results already stored for it, so the [GitLab project badge](/install/git-platforms/gitlab#project-badges) catches up without waiting for a push: a fully bypassed component reads `release bypassed`. The default-branch commit status keeps the un-bypassed verdict, since it gates nothing.

{% code title="Waive a known finding for a week while the fix lands" %}

```bash
lunar policy bypass-release github.com/my-org/my-service \
  --policy sbom.no-critical-vulns \
  --reason "upstream patch expected in v2.4, tracked in ENG-1234" \
  --for 1w
```

{% endcode %}

The command prints the stored record, including the `id` needed to revoke it.

#### `<component>`

* Type: `string`
* Optional

The component to override. Falls back to the `LUNAR_COMPONENT_ID` environment variable when no argument is given.

#### `--reason <text>`

* Type: `string`
* Required, unless [`bypass.require_reason`](/configuration/lunar-config#require_reason) is `false`

Why the block is being overridden. An unexplained override is not much of an audit trail, which is why this is required by default.

#### `--sha <git-sha>`

* Type: `string`
* Optional
* Default: `GITHUB_SHA` or `CI_COMMIT_SHA`, when neither `--sha` nor `--pr` is given

Limit the bypass to one commit.

#### `--pr <pr-number>`

* Type: `integer`
* Optional

Limit the bypass to one PR or MR number.

#### `--policy <selector>`

* Type: `string`
* Optional

Limit the bypass to one plugin (`sbom`) or one check (`sbom.no-critical-vulns`). Unset covers every blocking check for the gate.

#### `--for <duration>`

* Type: `duration`
* Optional
* Default: the configured [`bypass.max_duration`](/configuration/lunar-config#bypass)

How long the bypass lasts. Rejected if it exceeds the configured cap.

#### `--actor <name>`

* Type: `string`
* Optional
* Default: `GITLAB_USER_LOGIN`, `GITHUB_ACTOR`, or `USER`

Who is overriding the block. Recorded **unverified** — the output labels it as such. The command fails if no actor can be determined.

### `lunar policy bypass-pr`

* Form:

  ```bash
  lunar policy bypass-pr <component> --reason "<why>" [--for <duration>]
  lunar policy bypass-pr --reason "<why>"  # uses LUNAR_COMPONENT_ID
  ```

Records a time-bound override of a component's **PR/MR merge** block, so `lunar policy ok-pr` passes for as long as it lasts.

The bypass also clears the Git platform's `Earthly Lunar` signal: the [GitHub check run](/install/git-platforms/github#blocking-merges) that branch protection can require, and on GitLab the [external status check](/install/git-platforms/gitlab#merge-gate) where the merge gate is live, or the commit status that **Pipelines must succeed** reads elsewhere. The checks it covers stop counting against the rollup, and the PR/MR comment lists each of them with who bypassed it and until when. The signal is pushed, not polled, so it updates on the next policy evaluation (a new commit, a collection) rather than the moment the bypass is created or expires.

Takes exactly the same arguments and flags as [`lunar policy bypass-release`](#lunar-policy-bypass-release); only the gate differs. A bypass on one gate has no effect on the other, so a policy that blocks both PRs and releases needs one of each.

### `lunar policy bypass-ls`

* Form:

  ```bash
  lunar policy bypass-ls <component> [--active]
  lunar policy bypass-ls  # uses LUNAR_COMPONENT_ID
  ```

Lists a component's bypasses, newest first. Expired and revoked records are included by default.

```
  id:        6f1c2f8e-6a2d-4d3f-8a45-2b0f9c7d1e34
  component: github.com/my-org/my-service
  gate:      release
  scope:     policy sbom.no-critical-vulns
  reason:    upstream patch expected in v2.4, tracked in ENG-1234
  actor:     alice (unverified)
  source:    cli
  created:   2026-08-10T09:00:00Z
  expires:   2026-08-17T09:00:00Z
  status:    active
```

`scope` reads `entire component, every blocking check` when nothing narrows the bypass. `source` is where the bypass came from: `cli`, `github`, `gitlab`, or `ui`.

`status` names why a bypass is or is not in effect, and matches what the Bypasses dashboard reports:

| Status                                                 | Meaning                                                                                               |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `active`                                               | Still masks its checks.                                                                               |
| `revoked <when> by <who>`                              | Ended early with `bypass-rm`.                                                                         |
| `spent — PR #N was merged or closed`                   | A PR-gate bypass whose PR is finished; nothing is left to authorize.                                  |
| `superseded — a later push moved PR #N past sha <sha>` | A PR-gate bypass pinned to a commit the PR head has since moved past; the new push re-armed the gate. |
| `expired`                                              | Its `--for` window ran out.                                                                           |

The last two are the reason a commit-bound bypass (`expires: never`) can still be inactive: its bound is the commit and the PR, not the clock.

A comment-driven bypass reads differently on two lines, since the platform verified who asked and the commit is the bound:

```
  actor:     alice (verified: github:maintain)
  source:    github
  expires:   never — bound to its commit (a new push re-arms the gate)
```

#### `<component>`

* Type: `string`
* Optional

The component whose bypasses to list. Falls back to `LUNAR_COMPONENT_ID`.

#### `--active`

* Type: `boolean`
* Optional
* Default: `false`

Show only bypasses with `status: active` — not expired, not revoked, and for PR-gate bypasses, not superseded by a newer push and not on a merged or closed PR.

#### `--sha <git-sha>`

* Type: `string`
* Optional

Show only bypasses that cover this SHA.

#### `--pr <pr-number>`

* Type: `integer`
* Optional

Show only bypasses that cover this PR or MR number.

### `lunar policy bypass-rm`

* Form:

  ```bash
  lunar policy bypass-rm <bypass-id>
  ```

Ends a bypass before it expires, taking the block that it was masking back into effect.

This is a revoke, not a delete: the record survives with the revoker and timestamp attached, and still appears in `bypass-ls` without `--active`. Revoking an id that does not exist, or one that is already revoked, is an error.

#### `<bypass-id>`

* Type: `string`
* Required

The id of the bypass to revoke, as printed by `bypass-ls` or by the command that created it.

#### `--actor <name>`

* Type: `string`
* Optional
* Default: `GITLAB_USER_LOGIN`, `GITHUB_ACTOR`, or `USER`

Who is revoking the bypass. Recorded unverified, like the creating actor.

## SDK Commands

### `lunar catalog`

Saves catalog-related information from within a cataloger.

For detailed documentation on the `lunar catalog` command and all its options, see the [Cataloger Bash SDK](/plugin-sdks/bash-sdk/cataloger) page.

### `lunar collect`

Collects SDLC metadata into a component's JSON. Pass `--component` and `--sha` to collect against a specific component and commit from anywhere.

For detailed documentation on the `lunar collect` command and all its options, see the [Collector Bash SDK](/plugin-sdks/bash-sdk/collector) page.

## SQL Commands

### `lunar sql connection-string`

* Form:

  ```bash
  lunar sql connection-string
  ```

The `lunar sql connection-string` command returns the PostgreSQL connection string that can be used with any PostgreSQL client. The access is **read-only** and restricted to only the views described in the [SQL API](/sql-api/sql-api) documentation.

Example:

```bash
# Get the connection string
lunar sql connection-string
# Connect using psql (interactive)
psql $(lunar sql connection-string)
# Export checks data as CSV
psql $(lunar sql connection-string) -c "COPY (
  SELECT *
  FROM checks
  WHERE component_id = 'github.com/my-org/my-repo'
    AND status = 'fail'
) TO STDOUT WITH CSV HEADER" > failed_checks.csv
# Export component data as JSON
psql $(lunar sql connection-string) -c "
  SELECT json_agg(row_to_json(c))
  FROM (
    SELECT *
    FROM components
    WHERE tags @> '{\"team\":\"ui\"}'
  ) c" > platform_components.json
# Make decisions based on check results
psql $(lunar sql connection-string) -t -c "
  SELECT COUNT(*)
  FROM checks c
  JOIN components comp ON c.component_id = comp.component_id
  WHERE comp.domain = 'payments'
    AND c.status = 'fail'
    AND c.enforcement IN ('block-pr-and-release', 'block-release')" | \
  grep -q "^0$" || \
  (echo "Release blocking checks are failing in payments domain!" && exit 1)
```

For more examples of the SQL API in action, see the [SQL API](/sql-api/sql-api) documentation.

### `lunar secret set`

```bash
  lunar secret set <name> [value]
```

Set a secret that will be available to collectors, policies, or catalogers as `LUNAR_SECRET_<NAME>`.

If `value` is omitted, it is read from stdin (recommended for sensitive values to avoid shell history exposure).

{% hint style="warning" %}
**Typing the value at the prompt? End it with Ctrl+D to signal EOF.** With `value` omitted, `lunar secret set` reads stdin until end-of-input.
{% endhint %}

Secrets are encrypted at rest using AES-256-GCM. The Hub must have `HUB_SECRETS_ENCRYPTION_KEY` configured.

#### Options

* `--scope <scope>` — Secret scope: `collector` (default), `policy`, or `cataloger`.

#### Examples

```bash
# Set a collector secret (value as argument)
lunar secret set GH_TOKEN ghp_abc123

# Set a secret from stdin (recommended)
echo "ghp_abc123" | lunar secret set GH_TOKEN

# Set a secret by typing it in (press Ctrl+D to finish)
lunar secret set GH_TOKEN

# Set a policy secret
lunar secret set --scope policy JIRA_API_KEY my-api-key
```

### `lunar secret delete`

```bash
  lunar secret delete <name>
```

Delete a previously configured secret.

#### Options

* `--scope <scope>` — Secret scope: `collector` (default), `policy`, or `cataloger`.

#### Examples

```bash
lunar secret delete GH_TOKEN
lunar secret delete --scope policy JIRA_API_KEY
```

### `lunar secret list`

```bash
  lunar secret list
```

List the names of configured secrets for a given scope. Values are never displayed.

#### Options

* `--scope <scope>` — Secret scope: `collector` (default), `policy`, or `cataloger`.

#### Examples

```bash
# List collector secrets
lunar secret list

# List policy secrets
lunar secret list --scope policy
```

## Queue Commands

Collectors, policies and catalogers are dispatched through a queue and executed in the background. These commands let you inspect that queue and empty it, which is how you recover when a misconfiguration has filled it with work that can only fail and retry.

Jobs that are already running are never deleted. They finish on their own.

### `lunar queue status`

```bash
  lunar queue status
```

Show how many snippet executions are queued, broken down by type.

```
TYPE         CLEARABLE   RETRYING   RUNNING   DISCARDED
collectors        1204        892         6           0
policies            12          0         2           3
catalogers           0          0         0           0
```

* **CLEARABLE** — jobs `lunar queue clear` would delete: waiting to start, plus backing off after a failure.
* **RETRYING** — the subset of clearable jobs that have already failed at least once. A large number here is the signature of a failure loop.
* **RUNNING** — currently executing. Never deleted by a clear.
* **DISCARDED** — out of retries. These consume no capacity and are cleaned up automatically, so a clear leaves them alone.

### `lunar queue clear`

```bash
  lunar queue clear <collectors|policies|catalogers|all>
```

Delete queued snippet executions of the given type.

Run it without `--yes` first: it prints exactly what would be removed and stops without deleting anything. Add `--yes` to go through with it. There is no interactive prompt, so the command behaves identically in a terminal, through a pipe, and in CI.

{% hint style="warning" %}
Cleared jobs are gone — they are not rescheduled. Collectors and policies re-run on the next push to a component, but any result that a cleared job would have produced for an in-flight commit will be missing until then.
{% endhint %}

#### Options

* `--yes` — Actually delete. Without it the command previews and exits.

#### Examples

```bash
# See what's queued
lunar queue status

# Preview what clearing collectors would remove — deletes nothing
lunar queue clear collectors

# Go through with it
lunar queue clear collectors --yes
```

If the queue still shows running jobs after a clear, that is expected — a clear never interrupts work in progress.

## Utility Commands

### `lunar clear-cache`

* Form:

  ```bash
  lunar clear-cache
  ```

The `lunar clear-cache` command deletes Git and installation file caches used by Lunar. This can be useful to resolve issues caused by stale cached data.

### `lunar version`

* Form:

  ```bash
  lunar version
  ```

The `lunar version` command prints the Lunar CLI version and commit SHA.


# PR/MR Comments Reference

Reference for the /lunar commands Lunar accepts as comments on pull requests and merge requests, and what each one does to the merge gate.

Lunar watches the comments of GitHub pull requests and GitLab merge requests for commands addressed to it. They operate the merge gate's break-glass from the thread: one command overrides a block, the other takes the override back.

Two rules hold for every command:

* **The command must start the comment.** A mention mid-sentence ("we could `/lunar bypass` this") is conversation, and Lunar ignores it.
* **Lunar answers a command it recognizes.** An honored one is confirmed, and a refused one is answered with the reason rather than ignored. GitLab replies inside the command's discussion; GitHub posts a new comment on the Conversation tab, since a reply is audit evidence and is never edited afterwards.

A command on a request Lunar does not govern gets no reply at all: an untracked repository, or a request whose target branch matches no component. This is deliberate, because one repository can carry webhooks for several Hubs, but from the thread it looks the same as a dropped command. [When a command fails](#when-a-command-fails) covers the other reason a thread stays silent.

## Availability

* **GitHub**: any pull request where the [`Earthly Lunar` check run](/install/git-platforms/github#blocking-merges) reports. Whether that check blocks the merge is your branch-protection setting; the commands behave the same either way. Write the command as a plain comment in the **Conversation** tab, since comments inside a review, on a diff line or in the review summary, do not reach Lunar.
* **GitLab**: merge requests on any project Lunar tracks, on every tier. The commands operate whichever [merge gate](/install/git-platforms/gitlab#merge-gate) the project has, the external status check on Ultimate and the `Earthly Lunar` commit status on Free and Premium. As on GitHub, whether that signal blocks the merge is your setting (**Pipelines must succeed**, in the commit-status case), and the commands behave the same either way.

The grammar is identical on both platforms, so the phrase an engineer types does not depend on where the repository lives. Only the authorizing role differs, and each command below spells it out.

## `/lunar bypass`

* Form:

  ```
  /lunar bypass: <reason>
  ```

Overrides the merge gate for the request's **current head commit**. Lunar flips its signal to passing (the check run on GitHub, and on GitLab the external status check or, where the tier has none, the commit status), records the override on the audit ledger, and replies confirming who bypassed, which commit it covers, and why. To customize that reply, set [`customization.bypass_template`](/configuration/lunar-config/customization#bypass_template).

Lunar also refreshes the results comment, so it stops listing the bypassed checks as failing and credits the override instead. The refresh re-posts the results already stored for the commit rather than re-running the policies, so it needs neither a push nor a fresh evaluation. A commit Lunar has never evaluated has nothing stored to re-post and gets a full evaluation, which is slower.

### `<reason>`

* Required

Why the gate is being overridden, recorded verbatim on the ledger. It may span multiple lines. A comment without one (`/lunar bypass` alone, or `/lunar bypass:` with nothing after it) is refused with usage guidance.

### Authorization

| Platform | Required role                           |
| -------- | --------------------------------------- |
| GitHub   | `maintain` or `admin` on the repository |
| GitLab   | Maintainer or above on the project      |

These are the platforms' own roles. Lunar checks them with the platform when the comment arrives and has no role system of its own, so you grant them as usual: collaborator and team settings on a GitHub repository, project or inherited group membership on GitLab. GitHub computes the role across repository, team, organization, and enterprise grants; a custom role qualifies when its base permissions include `maintain` or `admin`, and is recorded under its own name. On older GitHub Enterprise Server versions that do not report a role name, only `admin` qualifies, because those versions report `maintain` and plain `write` as the same value.

The role that authorized the bypass is recorded with it, provider-qualified: `github:admin`, `gitlab:maintainer`, and so on.

The request's own author may bypass: a self-bypass is allowed, and recorded as such.

### Scope

A bypass covers **one commit**: pushing again re-blocks the request and needs a fresh bypass. Unlike a CLI bypass it carries no expiry; for a time-bound override, use [`lunar policy bypass-pr`](/docs/lunar-cli#lunar-policy-bypass-pr) from the CLI, which clears the same gate.

### Audit trail

Every bypass lands on the same ledger the [CLI bypass commands](/docs/lunar-cli#bypassing-a-block) write: who, their **verified** role, the reason, the commit, and whether it was a self-bypass. A comment-driven override therefore shows up in `lunar policy bypass-ls`, can be ended early with [`/lunar bypass rm`](#lunar-bypass-rm) or [`lunar policy bypass-rm`](/docs/lunar-cli#lunar-policy-bypass-rm), records [what it actually masked](/sql-api/views/bypassed-checks) when the pull or merge request merges, and is queryable over the [SQL API](/sql-api/views/bypasses) alongside every other override.

## `/lunar bypass rm`

* Form:

  ```
  /lunar bypass rm
  /lunar bypass rm <reason>
  /lunar bypass rm: <reason>
  ```

Withdraws the overrides granted by `/lunar bypass` comments on the same request, without leaving the thread. Lunar re-arms the gate for the current commit, confirms with a reply, and refreshes the results the same way a grant does. If the request has no comment-driven bypass to withdraw, Lunar says so instead. To customize the reply, set [`customization.bypass_revocation_template`](/configuration/lunar-config/customization#bypass_revocation_template).

### `<reason>`

* Optional

Why the override is being taken back. It appears in the confirmation reply; the ledger keeps the reason the bypass was granted with either way.

### Authorization

Revoking is held to the same bar as granting (`maintain` or `admin` on GitHub, Maintainer or above on GitLab), so nobody below that bar can re-block a merge someone else authorized.

### What it withdraws

Only the overrides made by `/lunar bypass` comments on this pull or merge request. Anything else is left alone:

* A [`lunar policy bypass-pr`](/docs/lunar-cli#lunar-policy-bypass-pr) made from the CLI records an intent formed outside the thread, usually with an expiry. Withdraw it with [`lunar policy bypass-rm`](/docs/lunar-cli#lunar-policy-bypass-rm) where it was made.
* Comment bypasses on other requests, and component-wide bypasses, are untouched.

### The gate afterwards

{% hint style="info" %}
**The check reports pending, not failed.** Lunar cannot recompute the verdict at the moment you revoke, so it reports that it has no current verdict, which blocks the merge: the check run goes back to in progress on GitHub, the external status check goes to pending on GitLab Ultimate, and the commit status returns to running on the tiers below it. Revoking also refreshes the results, and that post settles the check, without a push.
{% endhint %}

### Soft revoke

Revocation is the same soft revoke the CLI performs: the ledger row survives with the revoker and timestamp attached, and still appears in `lunar policy bypass-ls` without `--active`. A second `rm` on the same request finds nothing left to withdraw, and Lunar replies saying so.

## When a command fails

Either command can fail before Lunar acts on it, most often because the Git platform is rate limiting Lunar and will not serve the pull or merge request. Lunar answers in the thread saying what went wrong: a GitHub rate limit names how long its window has left to run, and a GitLab one asks you to try again in a few minutes. Nothing was recorded and the gate is untouched, so comment the command again to retry.

Every failure is also recorded as a failed webhook delivery on the platform, under **Settings → Webhooks → Recent Deliveries** on GitHub and **Settings → Webhooks → Recent events** on GitLab. Redelivering one from there re-runs the command.

An exhausted rate limit is the one failure that cannot announce itself, since posting a reply spends the same budget the command just failed on. If a command gets no reply at all, check `lunar policy bypass-ls` to see whether the override was recorded before commenting it again.


# lunar-config.yml

Reference for lunar-config.yml — the central configuration file defining hub connection, catalogers, domains, components, collectors, initiatives, and policies.

* `lunar-config.yml`
* Type: YAML file
* Form:

  ```yaml
  version: 0

  default_image: <default-image>
  default_image_ci_collectors: <default-image-ci>
  default_image_non_ci_collectors: <default-image-non-ci>
  default_image_policies: <default-image-policies>
  default_image_catalogers: <default-image-catalogers>

  image_replace:
    - from_pattern: <regex>
      to: <replacement>
    - ...

  hub:
    host: <hub-host>
    grpcPort: <grpc-port>
    httpPort: <http-port>
    insecure: <insecure-flag>

  bypass:
    max_duration: <duration>
    require_reason: <require-reason-flag>

  customization:
    bypass_template: <repository-relative-template-path>
    bypass_revocation_template: <repository-relative-template-path>
    checks_template: <repository-relative-template-path>
    ok_release_template: <repository-relative-template-path>
    pr_comments:
      mode: <comment-mode>
    bypass_hint:
      pr: <inline-markdown>
      release: <inline-markdown>

  catalogers:
    - <cataloger-object>
    - <cataloger-object>
    - ...

  domains:
    <domain-name>: <domain-object>
    <domain-name>: <domain-object>
    ...

  components:
    <component-name>: <component-object>
    <component-name>: <component-object>
    ...

  collectors:
    - <collector-object>
    - <collector-object>
    - ...

  initiatives:
    - <initiative-object>
    - <initiative-object>
    - ...

  policies:
    - <policy-object>
    - <policy-object>
    - ...
  ```

The file `lunar-config.yml` is used to configure the behavior of Lunar.

{% hint style="info" %}
It is recommended that you create a new code repository for all Lunar configuration and place this file in the root of it.
{% endhint %}

At a high-level, the file contains information about how the Lunar primitives are configured, ranging from how information is collected from the SDLC via collectors, to how components are organized into domains, and how the health of components is evaluated via policies.

## Splitting the configuration

A single `lunar-config.yml` works well and is the typical setup. If you'd prefer to spread a larger configuration across several files — for example, one per team — you can: any `*.yml` / `*.yaml` files placed in a `lunar-config.d/` directory next to `lunar-config.yml` are merged into it, in lexical filename order.

```
lunar-config.yml          # version, hub, default images
lunar-config.d/
  collectors.yml
  team-frontend.yml
  team-backend.yml
```

The fragment directory is named after the entry point, with the extension replaced by `.d` — so a [non-default entry point](/docs/lunar-cli#repo) such as `lunar-config.dev.yml` reads `lunar-config.dev.d/`, and never the default's fragments.

`lunar-config.yml` stays the entry point and holds the singleton fields (`version`, `hub`, `bypass`, `customization`, `default_image*`, `image_replace`); each fragment contributes additional sections. List sections (`collectors`, `policies`, …) are concatenated; map sections (`domains`, `components`) are unioned by key, and a key defined in more than one place is an error rather than a silent override.

## `version`

* `lunar-config.yml -> version`
* Type: `string`
* Required

The version field is used to specify the version of the configuration file. The current version is `0`.

## Default Images

* `lunar-config.yml -> default_image*`
* Optional

These fields configure the default Docker images used to run collectors, policies, and catalogers. When set, scripts will run inside containers instead of natively on the host.

A common configuration is:

{% code title="lunar-config.yml" %}

```yaml
default_image: earthly/lunar-scripts:1.0.0
default_image_ci_collectors: native
```

{% endcode %}

This runs most scripts in containers while keeping CI collectors native for direct access to CI environments.

For detailed documentation on default images, image resolution order, and the official `earthly/lunar-scripts` image, see [Images](/configuration/lunar-config/images).

## `image_replace`

* `lunar-config.yml -> image_replace`
* Type: `list`
* Optional

Find/replace rules applied to image references after they are resolved, for pointing images at an internal registry mirror:

{% code title="lunar-config.yml" %}

```yaml
image_replace:
  - from_pattern: ^earthly/
    to: 111122223333.dkr.ecr.us-east-1.amazonaws.com/docker.io/earthly/
```

{% endcode %}

See [Images](/configuration/lunar-config/images#rewriting-image-references) for the matching rules and caveats.

## `hub`

* `lunar-config.yml -> hub`
* Type: `object`
* Required

The `hub` object contains configuration for the Lunar Hub server.

### `host`

* `lunar-config.yml -> hub.host`
* Type: `string`
* Required

The host field is used to specify the host of the Lunar Hub server. E.g. `hub.example.com`.

### `grpcPort`

* `lunar-config.yml -> hub.grpcPort`
* Type: `integer`
* Required

The grpcPort field is used to specify the port of the Lunar Hub server for GRPC connections.

### `httpPort`

* `lunar-config.yml -> hub.httpPort`
* Type: `integer`
* Required

The httpPort field is used to specify the port of the Lunar Hub server for HTTP connections.

### `insecure`

* `lunar-config.yml -> hub.insecure`
* Type: `boolean`
* Optional
* Default: `false`

The insecure field is used to specify whether to use insecure HTTP connections to the Lunar Hub server.

## `bypass`

* `lunar-config.yml -> bypass`
* Type: `object`
* Optional

The `bypass` object bounds the [`lunar policy bypass-pr` / `bypass-release`](/docs/lunar-cli#bypassing-a-block) commands.

{% code title="lunar-config.yml" %}

```yaml
bypass:
  max_duration: 3d
  require_reason: true
```

{% endcode %}

### `max_duration`

* `lunar-config.yml -> bypass.max_duration`
* Type: `string`
* Optional
* Default: `14d`

The longest a single bypass may last. This caps `--for`, and is also what an omitted `--for` resolves to, so no bypass is ever open-ended.

Accepts Go's duration grammar extended with `d` (24 hours) and `w` (7 days) units, mixable: `36h`, `3d`, `2w`, `1w2d`. A malformed or non-positive value is rejected when the configuration is loaded, rather than when someone next tries to create a bypass.

A `--for` above the cap is rejected.

The cap applies to a single bypass, not to a sequence of them: renewing an expired bypass is the intended pattern.

### `require_reason`

* `lunar-config.yml -> bypass.require_reason`
* Type: `boolean`
* Optional
* Default: `true`

Whether `--reason` is mandatory when creating a bypass. Set it to `false` only if the justification is being recorded somewhere else.

## `customization`

* `lunar-config.yml -> customization`
* Type: `object`
* Optional

The `customization` object controls optional changes to Lunar's presentation and behavior.

For available fields, see [customization](/configuration/lunar-config/customization).

## `catalogers`

* `lunar-config.yml -> catalogers`
* Type: `array`
* Optional

Catalogers are used to synchronize software catalog information (such as domains, and components) with external systems.

For information on how to configure catalogers, see [catalogers](/configuration/lunar-config/catalogers).

## `domains`

* `lunar-config.yml -> domains`
* Type: `object`
* Optional

Domains are used to group related components together. Domains are hierarchical and can contain other domains.

For information on how to configure domains, see [domains](/configuration/lunar-config/domains).

## `components`

* `lunar-config.yml -> components`
* Type: `object`
* Optional

Components are the units of code that Lunar monitors. A component can represent either a code repository, or a subdirectory in the case of a monorepo.

Components are associated with domains and can have tags. Through the tagging system, components are associated with collectors, and policies.

For information on how to configure components, see [components](/configuration/lunar-config/components).

## `collectors`

* `lunar-config.yml -> collectors`
* Type: `array`
* Required

Collectors are used to collect live information from various sources to associate with individual components.

For information on how to configure collectors, see [collectors](/configuration/lunar-config/collectors).

## `initiatives`

* `lunar-config.yml -> initiatives`
* Type: `array`
* Optional

Initiatives are used to group components together. Initiatives are associated with domains and can have tags.

For information on how to configure initiatives, see [initiatives](/configuration/lunar-config/initiatives).

## `policies`

* `lunar-config.yml -> policies`
* Type: `array`
* Required

Policies are used to define the rules that Lunar uses to evaluate the health of components. Policies are associated with domains and can be inherited by child domains.

For information on how to configure policies, see [policies](/configuration/lunar-config/policies).


# About images

Configure default container images for collectors, policies, and catalogers in lunar-config.yml, with override precedence rules.

Lunar supports running collectors, policies, and catalogers inside Docker containers. This provides isolation, reproducibility, and simplifies dependency management. Default images can be configured at multiple levels, with more specific settings overriding more general ones.

## Image Resolution Order

The image used to run a script is determined in the following order (first match wins):

1. **Script-level `image`** - Set directly on the collector, policy, or cataloger
2. **Plugin-level default** - Set in `lunar-collector.yml`, `lunar-policy.yml`, or `lunar-cataloger.yml`
3. **Global default** - Set in `lunar-config.yml`
4. **Implicit default** - `native` (no container)

Once an image has been resolved, the [`image_replace`](#rewriting-image-references) rules — if any are configured — are applied to the result.

## Global Default Images

Configure default images in `lunar-config.yml`:

{% code title="lunar-config.yml" %}

```yaml
version: 0

default_image: my-custom-image:alpine-1.2.3
default_image_ci_collectors: native
default_image_non_ci_collectors: my-image:v1.0
default_image_policies: another-image:latest
default_image_catalogers: yet-another-image:v2.0

# ... rest of configuration
```

{% endcode %}

### `default_image`

* `lunar-config.yml -> default_image`
* Type: `string`
* Optional
* Default: `native`

The global default image to use for all collectors, policies, and catalogers. This is overridden by the more specific `default_image_*` settings.

### `default_image_ci_collectors`

* `lunar-config.yml -> default_image_ci_collectors`
* Type: `string`
* Optional
* Default: value of `default_image`

The default image for CI collectors (collectors with hooks of type `ci-before-command`, `ci-after-command`, `ci-before-job`, `ci-after-job`). It is common to set this to `native` since CI collectors often need direct access to the CI environment.

### `default_image_non_ci_collectors`

* `lunar-config.yml -> default_image_non_ci_collectors`
* Type: `string`
* Optional
* Default: value of `default_image`

The default image for non-CI collectors (collectors with hooks of type `code`, `cron`).

### `default_image_policies`

* `lunar-config.yml -> default_image_policies`
* Type: `string`
* Optional
* Default: value of `default_image`

The default image for all policies.

### `default_image_catalogers`

* `lunar-config.yml -> default_image_catalogers`
* Type: `string`
* Optional
* Default: value of `default_image`

The default image for all catalogers.

## Plugin-Level Default Images

Plugins can define their own default images that override the global settings. This is useful when a plugin requires specific dependencies that are pre-installed in a custom image.

In `lunar-collector.yml`, `lunar-policy.yml`, or `lunar-cataloger.yml`:

```yaml
version: 0
name: my-plugin
description: A plugin with custom image defaults

default_image: my-org/my-plugin-image:v1.0
default_image_ci_collectors: native
default_image_non_ci_collectors: my-org/my-plugin-image:v1.0
default_image_policies: my-org/my-plugin-image:v1.0
default_image_catalogers: my-org/my-plugin-image:v1.0

# ... rest of plugin configuration
```

The same settings are available as at the global level. Plugin-level defaults override global defaults but are overridden by script-level `image` settings.

## Script-Level Image

Each individual collector, policy, or cataloger can specify its own `image` to override all defaults:

{% code title="lunar-config.yml" %}

```yaml
collectors:
  - runBash: lunar collect .file-count "$(find . | wc -l)"
    image: earthly/lunar-scripts:1.0.0
    hook:
      type: code
    on: [my-tag]
```

{% endcode %}

## The `native` Value

The special value `native` explicitly opts out of containerized execution. When `image: native` is set, the script runs directly on the host system using the native runtime (Python, Bash, etc.).

This is useful when:

* A default image has been configured, but a specific script needs to run natively
* CI collectors need direct access to the CI environment
* The script needs access to host-specific resources

## Rewriting Image References

Some clusters only permit images from an approved registry. `image_replace` rewrites image references after they have been resolved, so you can point every image at an internal mirror without owning the plugins that declare them.

{% code title="lunar-config.yml" %}

```yaml
version: 0

image_replace:
  - from_pattern: ^earthly/
    to: 111122223333.dkr.ecr.us-east-1.amazonaws.com/docker.io/earthly/
```

{% endcode %}

This rewrites `earthly/lunar-scripts:1.1.5` to `111122223333.dkr.ecr.us-east-1.amazonaws.com/docker.io/earthly/lunar-scripts:1.1.5`.

Because the rules run *after* resolution, they apply to every image the configuration produces — including images set by an imported plugin's `default_image`, which you cannot otherwise override from a consumer config.

### `image_replace`

* `lunar-config.yml -> image_replace`
* Type: `list`
* Optional

Each entry takes:

| Field          | Description                                                                                                            |
| -------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `from_pattern` | A [Go regular expression](https://pkg.go.dev/regexp/syntax) matched against the resolved image. Required.              |
| `to`           | The replacement. May reference capture groups from `from_pattern` as `$1`, `$2`, … A literal `$` must be written `$$`. |

Rules are evaluated in order and **the first matching rule wins** — later rules are not applied to an already-rewritten image, so two rules sharing a prefix cannot compound into a doubled registry path.

Patterns are **unanchored**, matching the other pattern-valued settings in `lunar-config.yml`. Anchor a registry prefix explicitly with `^`:

{% code title="lunar-config.yml" %}

```yaml
image_replace:
  # Rewrite only images that start with docker.io/, preserving the rest.
  - from_pattern: ^docker\.io/(.*)
    to: 111122223333.dkr.ecr.us-east-1.amazonaws.com/docker.io/$1
  # Anything else on Docker Hub with no registry prefix.
  - from_pattern: ^earthly/
    to: 111122223333.dkr.ecr.us-east-1.amazonaws.com/docker.io/earthly/
```

{% endcode %}

{% hint style="warning" %}
`$` means *end of text* in a regular expression, not "starts with". A pattern written `$earthly/` is valid and will simply never match anything. Lunar logs a warning for any `image_replace` rule that matched no images during a pull, which is the quickest way to catch this.
{% endhint %}

The `native` value is never rewritten. It names an execution mode rather than an image, so a broad pattern cannot accidentally containerize a script that is meant to run natively.

`image_replace` may be set in only one configuration document. Because the first matching rule wins, splitting the list across [multiple config files](/configuration/lunar-config) would make a rule's precedence depend on the order the files happen to merge in.

## Common Configuration Pattern

A common configuration is to use containers for most scripts but run CI collectors natively:

{% code title="lunar-config.yml" %}

```yaml
default_image: earthly/lunar-scripts:1.0.0
default_image_ci_collectors: native
```

{% endcode %}

This configuration:

* Runs all policies in containers
* Runs all catalogers in containers
* Runs non-CI collectors (code, cron, repo hooks) in containers
* Runs CI collectors natively for direct access to CI environment variables and tools

## Official Image: `earthly/lunar-scripts`

Lunar provides an official Docker image `earthly/lunar-scripts` that includes:

* Alpine Linux (or `-debian` variant for tools requiring glibc)
* Python 3 with venv
* Bash
* The `lunar-policy` Python package
* The `lunar` CLI
* Common tools: `jq`, `yq`, `curl`, `parallel`, `wget`

### Dependency Handling

The official `earthly/lunar-scripts` image automatically executes any `requirements.txt` and/or `install.sh` files it finds in the plugin directory as part of its entrypoint. This is a convenience feature to help you get up and running quickly during development.

**For production use, we recommend baking all dependencies directly into your image.** This approach provides:

* Faster startup times (no runtime installation)
* Reproducible builds
* Better caching and smaller attack surface
* Elimination of network dependencies at runtime

### Recommended Approach: Custom Image Inheriting from Official

The recommended approach is to create a custom Dockerfile that inherits from the official `earthly/lunar-scripts` image and installs your dependencies at build time:

{% code title="Dockerfile" %}

```dockerfile
FROM earthly/lunar-scripts:1.0.0

# Install system dependencies (if needed)
RUN apt-get update && apt-get install -y jq curl && rm -rf /var/lib/apt/lists/*

# Copy and install Python dependencies
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt

# Copy and run install script (if needed)
COPY install.sh /tmp/install.sh
RUN /tmp/install.sh && rm /tmp/install.sh
```

{% endcode %}

This pattern gives you all the benefits of the official image (Python, Bash, `lunar` CLI, `lunar-policy` package) while ensuring your dependencies are baked in for production use.

## Image Entrypoint Contract

Lunar executes snippets by passing two arguments to the image's entrypoint:

```
<entrypoint> <language> <main-script-path>
```

| Argument | Description                           | Example             |
| -------- | ------------------------------------- | ------------------- |
| `$1`     | The snippet language                  | `python`, `bash`    |
| `$2`     | Absolute path to the main script file | `/app/exec/main.py` |

The entrypoint is responsible for invoking the correct language runtime on the given script. The official `earthly/lunar-scripts` image ships an entrypoint at `/app/entrypoint.sh` that handles this automatically.

### Requirements for Custom Images

If you build a custom image, it must meet these requirements:

1. **Entrypoint at `/app/entrypoint.sh`** that accepts `(language, script_path)` as `$1` and `$2`. The simplest implementation:

```bash
#!/bin/bash
set -e
exec "$1" "$2"
```

2. **Language runtime on `PATH`** — the binary matching the snippet's language must be available:

| Language | Required binary |
| -------- | --------------- |
| Python   | `python`        |
| Bash     | `bash`          |

{% hint style="info" %}
Many base images ship `python3` without a `python` symlink. Ensure `python` resolves correctly (e.g., `RUN ln -sf /usr/bin/python3 /usr/bin/python`). The official `earthly/lunar-scripts` image handles this automatically.
{% endhint %}

**The recommended approach is to inherit from the official image**, which satisfies both requirements out of the box:

{% code title="Dockerfile" %}

```dockerfile
FROM earthly/lunar-scripts:1.0.0
# Add your dependencies — the entrypoint and runtimes are already set up.
RUN pip install --no-cache-dir my-package
```

{% endcode %}

### How Execution Works

Both the Docker engine (local development) and the Kubernetes operator use the same entrypoint contract. Lunar passes `[language, script_path]` as arguments to the container, and the image's entrypoint handles dispatching to the correct runtime.

This means a custom image tested locally via the Docker engine will behave identically when deployed to Kubernetes — the entrypoint runs in both environments with the same arguments.

### Mount Points

The following directories are available inside the container in both Docker and Kubernetes execution modes:

| Container Path | Description                                                                   |
| -------------- | ----------------------------------------------------------------------------- |
| `/app/exec`    | Plugin directory — script and plugin files                                    |
| `/app/work`    | Working directory — the component's code (for collectors and some catalogers) |
| `/app/lib`     | Library directory — bundle data for policies                                  |

The container's working directory is set to `/app/work`, so your scripts can access the component's files using relative paths. The `LUNAR_PLUGIN_ROOT` environment variable is set to `/app/exec`.

## Private Registry Authentication

To pull images from private Docker registries, configure the following environment variables:

* `LUNAR_DOCKER_REGISTRY_USER` - Username for Docker registry authentication
* `LUNAR_DOCKER_REGISTRY_PASS` - Password or token for Docker registry authentication


# catalogers

Define the catalogers section of lunar-config.yml — scripts that synchronize domain and component metadata from external systems.

* `lunar-config.yml -> catalogers`
* Type: `array`
* Form:

  ```yaml
  - <cataloger-object>
  - <cataloger-object>
  - ...
  ```

Catalogers are used to synchronize software catalog information, such as domains, and components, with external systems.

The catalog information resulting from catalogers is merged in the order in which the catalogers are defined. This means that if two catalogers define the same field (e.g. `owner`) within the same component, the last one will take precedence.

After the cataloger information is merged, the information collected from any [`lunar.yml`](/configuration/lunar-yml) files 🚧 Coming Soon, and any component and domain information from [`lunar-config.yml`](/configuration/lunar-config), is merged to form the final catalog JSON, which is then used by Lunar to form the structure of domains and components internally.

Example catalogers definition:

{% code title="lunar-config.yml" %}

```yaml
catalogers:
  - name: GitHub repos
    runBash: |-
        gh repo list <my-org> --json | ... | \
        lunar catalog --json '.components' -
    hook:
      type: cron
      schedule: "0 2 * * *"
  - name: Backstage sync
    runBash: |-
        curl <curl-options> https://<backstage endpoint>/api/catalog/entities/by-query ... | ... | \
        lunar catalog --json '.components' -
    hook:
      type: cron
      schedule: "0 2 * * *"
  - name: DB sync
    runBash: |-
        psql ... -c 'COPY (select * FROM services WHERE ...) TO STDOUT WITH CSV HEADER' | \
        csvjson --no-header-row | ... | \
        lunar catalog --json '.components' -
    hook:
      type: cron
      schedule: "0 2 * * *"
  - name: Pick up catalog files from some central repo
    runBash: |-
      cat verticals.toml | ... | lunar catalog --json '.domains' -
      cat services.toml | ... | lunar catalog --json '.components' -
    hook:
      type: cron
      schedule: "0 2 * * *"
  - name: Label any repo that has a certain CI pipeline as "production"
    runBash: |-
      if grep -r --include="*.yaml" --include="*.yml" '^name: Deploy$' ./.github/workflows ; then
        lunar catalog component --tag production
      fi
    hook:
      type: component-repo
  - name: Complex operation
    mainBash: ./my-script.sh
    hook:
      type: cron
      schedule: "0 2 * * *"
  - name: Use an external cataloger
    uses: github://third-party/some-cataloger@v1
```

{% endcode %}

## Cataloger

* `lunar-config.yml -> catalogers.<cataloger-index>`
* Type: `object`
* Forms
  * Uses form (use an external cataloger plugin):

    ```yaml
    name: <cataloger-name>
    description: <description>
    uses: <cataloger-repo>
    include: <include-array>
    exclude: <exclude-array>
    with:
        <input-name>: <input-value>
        ...
    image: <docker-image>
    ```
  * Run form (define a cataloger inline):

    ```yaml
    name: <cataloger-name>
    description: <description>
    run<language>: <script>
    hook: <hook-configuration>
    hooks:
      - <hook-configuration>
      - <hook-configuration>
      - ...
    image: <docker-image>
    ```
  * Main form (define a cataloger inline with a main file):

    ```yaml
    name: <cataloger-name>
    description: <description>
    main<language>: <script>
    hook: <hook-configuration>
    hooks:
      - <hook-configuration>
      - <hook-configuration>
      - ...
    image: <docker-image>
    ```

Catalogers are used to dynamically import information about components and domains from external systems. They are run on a schedule or in response to certain events, such as a commit to a repository.

Catalogers can either be imported (Uses form), defined as an inline command (Run form), or defined as a script with a main file (Main form). When a cataloger is defined inline (Run and Main forms), a hook must be specified to determine when the cataloger should run.

### `name`

* `lunar-config.yml -> catalogers.<cataloger-index>.name`
* Type: `string`
* Required for Run and Main cataloger forms, Optional for Uses cataloger form

The name of the cataloger. This is used to identify the cataloger in the Lunar UI and logs.

### `description`

* `lunar-config.yml -> catalogers.<cataloger-index>.description`
* Type: `string`
* Optional

A description of the cataloger. This is used to describe the purpose of the cataloger in the Lunar UI and logs.

### `uses`

* `lunar-config.yml -> catalogers.<cataloger-index>.uses`
* Type: `string`
* Forms
  * GitHub form: `github://<org>/<repo>@<version>`
  * GitLab form: `gitlab://<host>/<namespace>/<project>@<version>` (the host is required, including for `gitlab.com`)
  * Local form: `./<path-to-cataloger>`
* Required for Uses cataloger form

The `uses` field specifies an external (plugin) cataloger to use. The cataloger can be a third-party cataloger, or a local cataloger defined in a subdirectory. Browse the [available integrations](https://earthly.dev/lunar/integrations/) to find catalogers for your tools.

### `with`

* `lunar-config.yml -> catalogers.<cataloger-index>.with`
* Type: `object`
* Optional

The `with` field specifies the inputs to pass to the cataloger plugin. The inputs are defined in the cataloger's configuration file. Input values are available to cataloger scripts as `LUNAR_VAR_*` environment variables.

Plugin authors can also reference inputs in their plugin YAML definitions using the `${{ inputs.NAME }}` syntax.

A consumer can then override the schedule via `with`:

{% code title="lunar-config.yml" %}

```yaml
catalogers:
  - name: my-sync
    uses: github://my-org/sync-plugin@v1
    with:
      schedule: "*/10 * * * *"
```

{% endcode %}

See [cataloger plugins](/plugin-sdks/plugins/cataloger-plugins#inputs) for more details on defining plugin inputs.

### `include`

* `lunar-config.yml -> catalogers.<cataloger-index>.include`
* Type: `array`
* Optional

The `include` field specifies which sub-catalogers to include from an imported cataloger plugin. When a cataloger is imported via `uses`, it may define (or import) multiple sub-catalogers. Use `include` to control which of those sub-catalogers are used.

If neither `include` nor `exclude` is specified, all sub-catalogers are included by default.

### `exclude`

* `lunar-config.yml -> catalogers.<cataloger-index>.exclude`
* Type: `array`
* Optional

The `exclude` field specifies which sub-catalogers to exclude from an imported cataloger plugin. Use `exclude` when you want to include most sub-catalogers but skip a few specific ones.

If neither `include` nor `exclude` is specified, all sub-catalogers are included by default.

For example, if a cataloger called `backstage` includes sub-catalogers named `components`, `domains`, and `users`:

{% code title="lunar-config.yml" %}

```yaml
catalogers:
  # Include only the components sub-cataloger
  - uses: ./dir/backstage
    include: [components]

  # Include all except users
  - uses: ./dir/backstage
    exclude: [users]

  # Include components and domains only
  - uses: ./dir/backstage
    include: [components, domains]
```

{% endcode %}

### `run<language>`

* `lunar-config.yml -> catalogers.<cataloger-index>.run<language>`
* Type: `string`
* Required in Run cataloger form

Defines the command to execute when the cataloger is invoked. Only `Bash` is supported currently. So `runBash` is the only valid field.

Running Bash supports [installing dependencies](/plugin-sdks/bash-sdk/dependencies).

#### `runBash`

* `lunar-config.yml -> catalogers.<cataloger-index>.runBash`
* Type: `string`

The `runBash` field specifies the bash cataloger script to run.

### `main<language>`

* `lunar-config.yml -> catalogers.<cataloger-index>.main<language>`
* Type: `string`
* Required in Main cataloger form

Defines the main file path used to execute when the cataloger is invoked. Only `Bash` is supported currently. So `mainBash` is the only valid field.

The file path is relative to the directory of the configuration file that declares it.

Running Bash supports [installing dependencies](/plugin-sdks/bash-sdk/dependencies).

#### `mainBash`

* `lunar-config.yml -> catalogers.<cataloger-index>.mainBash`
* Type: `string`

The `mainBash` field specifies the bash cataloger script to run.

### `hook`

* `lunar-config.yml -> catalogers.<cataloger-index>.hook`
* Type: `object`
* One of `hook` or `hooks` is required in Run and Main cataloger forms

Using `hook` is equivalent to using a single hook in the `hooks` field. The `hook` field specifies when the cataloger should run.

For more information on how to configure hooks, see [hooks](/configuration/lunar-config/cataloger-hooks).

### `hooks`

* `lunar-config.yml -> catalogers.<cataloger-index>.hooks`
* Type: `array`
* One of `hook` or `hooks` is required in Run and Main cataloger forms

The `hooks` field specifies when the cataloger should run. Use this form when a cataloger needs multiple hooks.

For more information on how to configure hooks, see [hooks](/configuration/lunar-config/cataloger-hooks).

### `image`

* `lunar-config.yml -> catalogers.<cataloger-index>.image`
* Type: `string`
* Optional

The `image` field specifies the Docker image to use when running the cataloger. When set, the cataloger runs inside a container instead of natively on the host.

Use the special value `native` to explicitly run the cataloger without a container, even when a default image has been configured.

Example:

{% code title="lunar-config.yml" %}

```yaml
catalogers:
  # Run in a container
  - name: Sync from external system
    runBash: curl https://api.example.com/services | lunar catalog --json '.components' -
    image: earthly/lunar-scripts:1.0.0
    hook:
      type: cron
      schedule: "0 2 * * *"

  # Run natively (override any default image)
  - name: Local sync
    mainBash: ./sync-local.sh
    image: native
    hook:
      type: cron
      schedule: "0 3 * * *"
```

{% endcode %}

For more information about default images and container execution, see [Images](/configuration/lunar-config/images).


# catalogers/hooks

Configure cataloger hooks in lunar-config.yml — triggers like cron schedules or repository events that determine when catalogers run.

* `lunar-config.yml -> catalogers.<cataloger-index>.hook`
* `lunar-config.yml -> catalogers.<cataloger-index>.hooks`
* `lunar-cataloger.yml -> catalogers.<cataloger-index>.hook`
* `lunar-cataloger.yml -> catalogers.<cataloger-index>.hooks`
* Type: `object` (singular `hook`) or `array` (plural `hooks`)
* Form:

  ```yaml
  hook:
    type: <hook-type>
    <options>
  ```

  or

  ```yaml
  hooks:
    - type: <hook-type>
      <options>
    - type: <hook-type>
      <options>
    - ...
  ```

A cataloger hook defines a trigger point for when a cataloger should run. Catalogers can be triggered by various events such as code changes, or cron schedules. Both `hook` (singular) and `hooks` (plural array) are supported. Using `hook` is equivalent to using a single hook in the `hooks` field.

A hook has different configuration options depending on the type of event it is triggered by.

## Hook types

### `cron`

* Form:

  ```yaml
  type: cron
  schedule: <cron-schedule>
  ```

The `cron` type triggers the cataloger on a specified schedule. The schedule is defined using a cron expression.

### `repo`

* Form:

  ```yaml
  type: repo
  repo: github://<org>/<repo>
  # or, on GitLab:
  repo: gitlab://<host>/<namespace>/<project>
  ```

The `repo` type triggers the cataloger when a commit is made to a specified repository, named in the same URL format used elsewhere in the config. This cataloger type is most useful for centralized repositories that contain information about domains and/or components.

### `component-repo`

* Form:

  ```yaml
  type: component-repo
  clone-code: true # optional
  ```

The `component-repo` type triggers the cataloger when a commit is made to a component repository. This cataloger type is most useful when additional information about components is available in each of the respective repositories.

Although this cataloger type cannot be used to define new components, it can be used to augment the metadata (such as owner, description and tags) associated with existing components.

Set `clone-code: true` to have the component's repository checked out before the cataloger runs, so it reads repo-resident files (for example `catalog-info.yaml`, `CODEOWNERS`, or `lunar.yml`) directly from a working tree rather than fetching them through an API. The checkout is taken at the pushed commit for pushes to a branch the component tracks — the branch named by its `branch:` field, or the repository's default branch when it names none. That is the authoritative state the cataloger augments component metadata from; pushes to other branches (and branch deletions) run the cataloger without a checkout. Without `clone-code` the cataloger receives only the component identifier and no checkout.

### `component-cron`

* Form:

  ```yaml
  type: component-cron
  schedule: <cron-schedule>
  clone-code: true # optional
  ```

The `component-cron` type triggers a cataloger run for each component, on a specified schedule. Although this cataloger type cannot be used to define new components, it can be used to augment the metadata (such as owner, description and tags) based on its component JSON.

Set `clone-code: true` to have each component's repository checked out at the current HEAD of the branch it tracks (its `branch:` field, or the repository’s default branch when it names none) before the cataloger runs, so it reads repo-resident files (for example `catalog-info.yaml`, `CODEOWNERS`, or `lunar.yml`) directly from a working tree rather than fetching them through an API. A component whose repository isn't tracked yet, or has no commit ingested, runs without a checkout. Without `clone-code` the cataloger receives only the component identifier and no checkout.


# customization

Customize Lunar's presentation and behavior.

* `lunar-config.yml -> customization`
* Type: `object`
* Optional

The `customization` object controls optional changes to Lunar's presentation and behavior. Every setting under it rewrites text Lunar shows somewhere: a reply in a pull-request or merge-request thread, the checks report posted on that request, the verdict the `lunar` CLI prints in a CI job, or a line on a Grafana dashboard.

## Templates

Four settings take a template rather than a literal string: [`bypass_template`](#bypass_template), [`bypass_revocation_template`](#bypass_revocation_template), [`checks_template`](#checks_template) and [`ok_release_template`](#ok_release_template). Each is the path to a file written in Go's [`text/template`](https://pkg.go.dev/text/template), the templating language in the Go standard library: ordinary text with `{{ ... }}` actions in it, which Lunar executes against the data its own section lists.

The standard actions, `{{if}}`, `{{range}}`, `{{define}}` and the rest, behave as documented, as do the built-in functions. Lunar adds no helpers of its own and exposes no Git platform API objects. It sets `missingkey=error`, so naming a parameter Lunar does not supply is an error and not an empty string.

What follows holds for all four. [`pr_comments`](#pr_comments) and [`bypass_hint`](#bypass_hint) are not templates.

### Where the file lives

The path is relative to the configuration repository root, even when the `customization:` block is in `lunar-config.d/`. Lunar rejects absolute paths, paths that point outside the repository, and symlinks that resolve outside it.

### When Lunar reads it

Lunar reads the file when it pulls the configuration, validates it by rendering it against representative data, and stores the contents with the manifest. Every later render uses that stored copy, so a Hub replica never needs the repository checkout, and neither does the CI runner that prints the `ok_release_template` output.

A missing file, a template that does not parse, a reference to a parameter that does not exist, or output that is empty or too large fails the pull. Lunar creates no new manifest, and the last published one stays active.

### Limits and fallback

Rendered output must be non-empty and no larger than 64 KiB.

Output that gets posted to a Git platform, meaning the two bypass replies and the checks report, must not contain a line beginning with `/`, because GitHub and GitLab read such a line as a command like `/approve` or `/merge`. Lunar checks this at configuration pull and again on the real output before posting. `ok_release_template` prints to a terminal, so the rule does not apply to it.

A template decides wording and nothing else: verdicts, exit codes and whether a gate blocks are all settled before it runs. If a stored template unexpectedly fails at render time, Lunar logs the error and falls back to the built-in text for that one render.

## `bypass_template`

* `lunar-config.yml -> customization.bypass_template`
* Type: `string`
* Optional

The reply Lunar posts once a [`/lunar bypass` comment](/docs/pr-comments#lunar-bypass) has granted a bypass. It lands in the thread the command was written in: on GitHub as a new comment on the pull request, on GitLab as a new note in the discussion the command started. These replies are the audit trail for an override, so every attempt gets its own and Lunar never edits an earlier one.

```yaml
customization:
  bypass_template: templates/bypass-acknowledgement.tmpl.md
```

If this field is omitted, Lunar posts its built-in acknowledgement, which is also a reasonable starting point for a custom one:

{% code title="templates/bypass-acknowledgement.tmpl.md" %}

```gotemplate
{{ if .Error -}}
🌙 **Earthly Lunar could not bypass the merge gate** for @{{ .Actor }}.

> {{ .Error }}

*Nothing was overridden and the gate still applies. Comment `/lunar bypass: <reason>` again to retry.*
{{- else -}}
🌙 **Earthly Lunar merge gate bypassed** by @{{ .Actor }} for commit `{{ .SHA }}`.

Reason:
> {{ .Reason }}

*This override covers only the commit above, so pushing a new commit re-arms the gate. Revoke it with `/lunar bypass rm`.*
{{- end }}
```

{% endcode %}

It opens on `.Error` because a command that failed is answered from this same template. See [reporting a command that failed](#reporting-a-command-that-failed).

Templates receive this typed data:

* `.Actor` (`string`): Git platform username or login, without `@`
* `.SHA` (`string`): full pull-request or merge-request head commit SHA
* `.Reason` (`string`): supplied reason, collapsed to one line before rendering
* `.PullRequestNumber` (`int64`): repository-local pull-request or merge-request number
* `.GitPlatform` (`string`): canonical Git platform name, such as `github` or `gitlab`
* `.Host` (`string`): Git platform host
* `.Repository` (`string`): `owner/repository` path
* `.SourceBranch` and `.TargetBranch` (`string`): pull-request or merge-request branches
* `.Components` (`[]string`): all matched Lunar component names
* `.SelfBypass` (`bool`): whether the commenter authored the pull request or merge request
* `.VerifiedRole` (`string`): provider-qualified role recorded in the bypass ledger, such as `gitlab:maintainer` or `github:admin`
* `.Error` (`string`): why the command could not be carried out, empty when it succeeded

### Reporting a command that failed

A `/lunar bypass` command can fail before Lunar acts on it, most often because the Git platform is rate limiting Lunar and will not serve the pull request. The commenter is told so in the thread, from this same template, with `.Error` set and every other parameter holding whatever was known when the command stopped. `.SHA` and `.Components` are empty when it stopped before resolving them.

Branch on `.Error` first, since the rest of the template describes an override that was never made:

```gotemplate
{{ if .Error }}🌙 Lunar could not bypass the gate for @{{ .Actor }}: {{ .Error }}
{{ else }}🌙 Bypassed by @{{ .Actor }} for commit `{{ .SHA }}`.
{{ end }}
```

A template without a branch for it keeps working. Configuration pull accepts it, and Lunar posts its built-in failure reply for that one comment. The confirmation the template was written for is unaffected.

### Extra limits on the bypass replies

Both bypass replies are held to the [shared limits](#limits-and-fallback), plus a few of their own that keep rendering work bounded. `call`, `html`, `js`, `print`, `printf`, `println`, `urlquery` and `with` are unavailable; recursive or overly complex template-call graphs are rejected; and a template may contain one `range`, directly over `.Components`. The file itself must also be no larger than 64 KiB, and the output must not contain `[Earthly Lunar]`, the marker reserved for Lunar's own results comment.

Do not open a template with `.Reason`. The commenter writes it, so a reason spelled `/lunar bypass: ...` would make the reply itself a command. Lunar discards any reply that reads as one and posts its built-in text instead.

## `bypass_revocation_template`

* `lunar-config.yml -> customization.bypass_revocation_template`
* Type: `string`
* Optional

The reply Lunar posts once a [`/lunar bypass rm` comment](/docs/pr-comments#lunar-bypass-rm) has withdrawn a bypass. It lands in the same place, and the same way, as the grant reply above.

```yaml
customization:
  bypass_revocation_template: templates/bypass-revocation.tmpl.md
```

Grants and revocations use separate templates, so customizing the grant reply leaves the revocation reply on its built-in text.

The built-in revocation reply, which branches on `.Error` for the same reason:

{% code title="templates/bypass-revocation.tmpl.md" %}

```gotemplate
{{ if .Error -}}
🌙 **Earthly Lunar could not revoke the merge gate bypass** for @{{ .Actor }}.

> {{ .Error }}

*Nothing was withdrawn and any existing override still stands. Comment `/lunar bypass rm` again to retry.*
{{- else -}}
🌙 **Earthly Lunar merge gate bypass revoked** by @{{ .Actor }} for commit `{{ .SHA }}`.
{{ if .Reason }}
Reason:
> {{ .Reason }}
{{ end }}
*The gate is armed again for this commit and reports pending while Lunar re-evaluates it. Comment `/lunar bypass: <reason>` to override it again.*
{{- end }}
```

{% endcode %}

Revocation templates receive the same typed data as `bypass_template`, and are held to the same [extra limits](#extra-limits-on-the-bypass-replies). Two parameters read differently here: `.Reason` is the reason given on the `rm` comment, which is optional and empty when none was given, and `.Components` lists only the components whose bypasses were actually withdrawn. A `/lunar bypass rm` that fails is [reported through this template](#reporting-a-command-that-failed) rather than the grant one. If this field is omitted, Lunar posts its built-in revocation reply.

## `pr_comments`

* `lunar-config.yml -> customization.pr_comments`
* Type: `object`
* Optional

The `pr_comments` object controls when Lunar posts its results comment: a comment on a GitHub pull request, or a note on a GitLab merge request, carrying the body [`checks_template`](#checks_template) renders. Only that comment is affected. Commit statuses, GitHub check runs, the merge gate and the Grafana dashboards all keep reporting on every run.

```yaml
customization:
  pr_comments:
    mode: only_failures
```

### `mode`

* `lunar-config.yml -> customization.pr_comments.mode`
* Type: `string`
* Optional
* Default: `always`

When the results comment is posted. One of:

* `always`: post the comment and keep it updated on every run, including the live "pending" view while collectors are still reporting. This is the default and the historical behavior.
* `only_failures`: the comment is only posted if there are one or more failing checks. Otherwise the comment is omitted entirely. Pending checks are shown by the check run (GitHub) or commit status (GitLab), not by a comment. Once posted, the comment keeps updating as usual, back to all-green once the failures are fixed.

## `checks_template`

* `lunar-config.yml -> customization.checks_template`
* Type: `string`
* Optional

The full Markdown body of the `Earthly Lunar` checks report, the list of policy checks and their status that Lunar publishes for a commit. One template covers all three places the report goes, and `.Surface` tells it which one it is rendering:

* the output text of the GitHub check run (`github-check`)
* Lunar's comment on a GitHub pull request (`github-pr-comment`)
* Lunar's note on a GitLab merge request (`gitlab-mr-note`)

The Grafana dashboards build their own view of the same results, so this template does not reach them.

```yaml
customization:
  checks_template: templates/checks-report.tmpl.md
```

Without `checks_template`, Lunar uses the standard built-in report.

The template cannot change check names, GitHub conclusions, GitLab states, required-check calculation, bypass decisions, or heartbeat behavior.

### Template data

The template renders once for the entire report and receives these report fields:

* `.GitPlatform`: `github` or `gitlab`
* `.Surface`: `github-check`, `github-pr-comment`, or `gitlab-mr-note`
* `.Host`: Git platform host
* `.Repository`: owner and repository path
* `.SHA`: commit being reported
* `.PullRequestNumber`: pull request or merge request number from the first component scope, or `0` for a default-branch check
* `.Components`: all matched component/PR scopes, sorted by full component name and then PR number
* `.Compact`: `true` when a report exceeded the output limit and Lunar retries with a compact rendering

Each entry of `.Components` has these fields:

* `.Component`: full Lunar component name
* `.Name`: short component name, matching the UI
* `.Path`: repository-relative path starting with `/`; the repository root is `/`
* `.PullRequestNumber`: PR or MR number for this component scope
* `.MultiplePRs`: whether this component appears in more than one PR scope at the reported SHA
* `.DetailsURL`: component dashboard URL
* `.Sections`: this component's checks grouped by status
* `.Bypassed`: this component's bypassed checks
* `.Pending`, `.Failed`: evaluation state, separate from individual check results
* `.CommitBypassID`: active commit bypass ID, or an empty string
* `.Empty`: no displayed check sections, bypasses, or pending/failed evaluation to report
* `.Summary`: component verdict summary for compact rendering
* `.GitPlatform`: the report's Git platform, available inside component subtemplates

Your template controls the full layout, including the title, component headings, and evaluation notices. The built-in template hides empty component sections in PR comments; custom templates receive those components and choose whether to show them. The check verdict still includes every matched component.

For example, this template uses one title and lists every component, including empty ones:

```gotemplate
## Policy report

{{range .Components}}### {{.Name}}

`{{.Path}}`

{{if .Pending}}Evaluation pending.{{else if .Failed}}Evaluation failed.{{end}}
{{range .Sections}}* {{.Count}} {{.Title}}
{{end}}
[Details]({{.DetailsURL}})

{{end}}
```

Lunar selects the template from the first matched component's published manifest in the order above. The selection stays the same whichever component triggers the post. A custom PR comment also receives an invisible ownership marker so Lunar can update it later.

Existing templates can continue using top-level `.Component`, `.DetailsURL`, `.Sections`, and `.Bypassed` for single-component reports. These fields are unavailable at the report root when multiple components or PR scopes are present. If an existing template accesses them on a monorepo, Lunar logs the error and uses the built-in report for that render. Move these accesses inside `{{range .Components}}` to customize monorepo reports.

The size and output checks apply to the complete rendered report. If the report remains too large with `.Compact` set, posting fails rather than publishing a truncated report that loses components. The built-in compact rendering retains each visible component's verdict and details link.

Each entry of `.Sections` carries `.Status` (`pass`, `fail`, `no-data`, `skipped`, `error`, or `unknown`), `.Title`, `.Summary`, `.Open`, `.HasRequired`, `.Count`, `.Checks`, and `.AdditionalChecks`, the overflow the built-in template folds into a "more..." block.

`.HasRequired` is true when at least one check in the section can block the gate of the surface being rendered: `block-pr` or `block-pr-and-release` on a pull or merge request, `block-release` or `block-pr-and-release` on a default-branch check run. The built-in template keys the `/lunar bypass` line on it, so a failing section holding only `report-pr` checks reports the failures and offers no command. `.Required` is the same test applied to a single check: the built-in template takes the section icon from `.HasRequired` and each check's icon from `.Required`.

Each check in `.Checks` and `.AdditionalChecks` carries `.Name`, `.PolicyName`, `.Description`, `.Enforcement`, `.Required`, `.Status`, `.FailureMessages`, `.MoreAssertions`, `.Error`, and `.FailureText`. Each entry of `.Bypassed` carries `.Name`, `.Actor`, `.Reason`, `.ExpiresAt` (RFC 3339, empty for a commit-bound bypass), `.CommitBound`, `.FailureMessages`, and `.MoreAssertions`.

`.FailureText` is the policy's own [`failureText`](/configuration/lunar-config/policies#failuretext), already rendered and already indented two spaces, and it is empty for every policy that does not set one. The built-in template prints it in place of the `.FailureMessages` bullets, which is what makes `failureText` a replacement rather than an addition; a custom template that ignores the field leaves every policy on the default list.

A check's `.Name` is qualified by the policy it came from, such as `container-scan.max-severity`, so two policies contributing a check of the same name render as two distinguishable lines. Lunar appends the check name to the policy name unless the policy name already ends with it, so the rendered name is also one [`lunar policy bypass-pr`](/docs/lunar-cli#lunar-policy-bypass-pr) accepts for `--policy`. `.PolicyName` carries the policy on its own.

Every name in the report is built this way, `.Bypassed` included, so one report never spells the same check two ways. A check that failed before it could report a name, such as one whose policy image could not be pulled, is listed under its policy's name; `Execution failure` appears only when the policy is unknown too. Within a section, `.Checks` arrives with the required checks first, and ordered by that rendered name within each group.

The built-in template is:

```gotemplate
## 🌙 [Earthly Lunar](https://docs-lunar.earthly.dev/)

{{$multiple := gt (len .Components) 1 -}}
{{$previous := "" -}}
{{$shown := false -}}
{{range .Components -}}
{{if not (and (eq $.Surface "github-pr-comment") .Empty) -}}
{{$shown = true -}}
{{if and $multiple (ne $previous .Component)}}### Component {{.Name}}

`{{.Path}}`

{{end -}}
{{$previous = .Component -}}
{{if .MultiplePRs}}**PR #{{.PullRequestNumber}}**

{{end -}}
{{if .CommitBypassID}}Required checks bypassed for this commit (bypass `{{.CommitBypassID}}`).

{{else if .Failed}}❗ Policy evaluation failed; results may be incomplete.

{{else if .Pending}}⏱️ Waiting for this component's policy evaluation.

{{else if and .Empty .Component}}No applicable policy checks.

{{end -}}
{{if $.Compact}}{{.Summary}}. Details omitted to fit GitHub's report limit.

[More Details]({{.DetailsURL}})

{{else}}{{template "component" .}}{{end -}}
{{end -}}
{{end -}}
{{if not $shown}}No applicable policy checks.

{{end -}}
{{define "component"}}{{if .Bypassed}}<details open>
<summary><strong>🔓 {{len .Bypassed}} Bypassed</strong></summary>
{{if eq .GitPlatform "github"}}<br>
{{end}}
*These required checks were failing, but an active bypass lets them through. The merge gate treats them as passing until the bypass expires or is revoked.*

{{range .Bypassed}}* 🔓 `{{.Name}}` — bypassed by {{.Actor}} {{if .CommitBound}}while its commit stands{{else}}until {{.ExpiresAt}}{{end}}: {{.Reason}}
{{range .FailureMessages}}  * {{.}}
{{end}}{{if gt .MoreAssertions 0}}  * {{.MoreAssertions}} more assertions weren't shown
{{end}}{{end}}
</details>

{{end}}{{range .Sections}}<details{{if .Open}} open{{end}}>
<summary><strong>{{template "section-icon" .}} {{.Count}} {{.Title}}</strong></summary>
{{if eq $.GitPlatform "github"}}<br>
{{end}}
{{if .Summary}}{{if eq .Status "error"}}*{{.Summary}}*{{else}}{{.Summary}}{{end}}

{{end}}{{if and (eq .Status "fail") .HasRequired}}*To bypass the required checks, comment with `/lunar bypass: <reason>`*

{{end}}{{range .Checks}}* {{template "check-icon" .}} `{{.Name}}`{{if .Description}} - {{.Description}}{{end}}
{{template "check-failures" .}}{{if gt .MoreAssertions 0}}  * {{.MoreAssertions}} more assertions weren't shown
{{end}}{{if .Error}}  * `{{.Error}}`
{{end}}{{end}}{{if .AdditionalChecks}}
<details>
<summary>{{len .AdditionalChecks}} more...</summary>

{{range .AdditionalChecks}}* {{template "check-icon" .}} `{{.Name}}`{{if .Description}} - {{.Description}}{{end}}
{{template "check-failures" .}}{{if gt .MoreAssertions 0}}  * {{.MoreAssertions}} more assertions weren't shown
{{end}}{{if .Error}}  * `{{.Error}}`
{{end}}{{end}}</details>
{{end}}
</details>

{{end}}{{if and .DetailsURL (or .Component .Sections .Bypassed)}}[More Details]({{.DetailsURL}})

{{end -}}
{{end -}}
{{define "check-failures"}}{{if .FailureText}}{{.FailureText}}{{else}}{{range .FailureMessages}}  * {{.}}
{{end}}{{end}}{{end -}}
{{define "section-icon"}}{{if eq .Status "fail"}}{{if .HasRequired}}❌{{else}}⚠️{{end}}{{else if eq .Status "no-data"}}⏱️{{else if eq .Status "pass"}}✅{{else if eq .Status "error"}}❗{{else}}⁉️{{end}}{{end -}}
{{define "check-icon"}}{{if eq .Status "fail"}}{{if .Required}}❌{{else}}⚠️{{end}}{{else if eq .Status "no-data"}}⏱️{{else if eq .Status "pass"}}✅{{else if eq .Status "error"}}❗{{else}}⁉️{{end}}{{end -}}
```

Validation renders the template once per surface, so one that produces nothing on, say, a GitHub check run fails the pull instead of leaving that surface blank later.

Before rendering, Lunar collapses every data value to one line, except failure messages and `.FailureText`, which keep their line structure: a message has each line after the first re-emitted as an indented list item, so multi-line findings render as a nested list, and `.FailureText` arrives with every one of its lines indented. Either way a value never opens a line of the report, which is what keeps a check name, assertion, bypass reason, or `failureText` from injecting a GitLab quick action.

## `ok_release_template`

* `lunar-config.yml -> customization.ok_release_template`
* Type: `string`
* Optional

The verdict [`lunar policy ok-release`](/docs/lunar-cli#lunar-policy-ok-release) prints when the release gate finishes. This is terminal output, so in practice you read it in the log of the CI job that runs the gate. It never reaches a pull or merge request.

```yaml
customization:
  ok_release_template: templates/ok-release.tmpl.md
```

The template replaces the block the CLI prints once the gate reaches a verdict: the summary line plus the failing and bypassed check lists. It does not change the progress messages printed while polling, and it does not apply to `lunar policy ok-pr`.

{% code title="templates/ok-release.tmpl.md" %}

```gotemplate
{{if .Ok}}Release approved for {{.Component}} at {{.SHA}}.
{{else}}Release blocked for {{.Component}} at {{.SHA}}. See https://wiki.example.com/release-gate before retrying.
{{range .FailingChecks}}- {{.PolicyName}}.{{.Name}} [{{.Status}}] ({{.Enforcement}})
{{range .FailureMessages}}  - {{.}}
{{end}}{{end}}{{end}}{{range .BypassedChecks}}Bypassed: {{.Name}} by {{.Actor}} {{if .CommitBound}}while its commit stands{{else}}until {{.ExpiresAt}}{{end}}: {{.Reason}}
{{end}}
```

{% endcode %}

### Template data

* `.Component` (`string`): full Lunar component name
* `.SHA` (`string`): commit the gate was asked about
* `.Ok` (`bool`): the verdict, `true` when nothing blocks the release
* `.FailingChecks`: checks blocking the release, each with `.Name`, `.PolicyName`, `.Status` (`fail` or `no-data`), `.Enforcement`, and `.FailureMessages`
* `.BypassedChecks`: failing checks an active bypass lets through, each with `.Name`, `.PolicyName`, `.Enforcement`, `.Actor`, `.Reason`, `.FailureMessages`, `.ExpiresAt` (RFC 3339, empty for a commit-bound bypass), and `.CommitBound`

Before rendering, Lunar collapses every data value to one line.

Validation renders the template against each verdict the gate can reach: a pass, a pass with bypassed checks, and a block. All three must produce output, so a template that covers a clean pass and a block but not a release that a bypass let through fails the pull instead of printing nothing on that run.

The rendered output is plain text the CLI prints verbatim, so the built-in colors and emoji are replaced by whatever the template produces. The verdict and the exit code always come from the policy evaluation, never from the template.

## `bypass_hint`

* `lunar-config.yml -> customization.bypass_hint`
* Type: `object`
* Optional

A line telling someone stuck behind a gate how to get past it: which channel to ask in, which runbook to follow, or which command to run.

It shows in the Grafana dashboards, directly under the gate banner in the page header. A blocked gate puts "2 checks are required to merge" or "1 check is required to release" in that banner, and the hint goes on the line below. Once every required check passes, or an active bypass clears them, the hint disappears.

The dashboards are the only place it reaches. The pull request comment, the GitHub check run, and the GitLab merge request note carry a built-in hint of their own pointing at [`/lunar bypass`](/docs/pr-comments#lunar-bypass); `bypass_hint` does not configure that one, and the only way to change it is to replace the whole [`checks_template`](#checks_template).

A hint appears only where a failing check can block the gate on the page: `block-pr` or `block-pr-and-release` on the PR details dashboard, `block-release` or `block-pr-and-release` on the component dashboard. Checks at `report-pr`, the default [enforcement](/configuration/lunar-config/policies#enforcement), or at `score` or `draft`, raise no banner and so produce no hint, on either dashboard and however the hint is set.

```yaml
customization:
  bypass_hint:
    pr: 'Ask in `#eng-guardrails`, or see the [bypass runbook](https://example.com/runbook).'
    release: 'Release blocks need a sign-off in `#eng-guardrails` before bypassing.'
```

* `pr` (`string`): shown on the **PR details** dashboard, under the merge-gate banner.
* `release` (`string`): shown on the **Component details** dashboard, under the release-gate banner.

Each message is a single paragraph of inline Markdown (code spans, links, bold) of at most 4 KiB. Lunar converts it to HTML when it pulls the configuration, so a message that is oversized, not a single paragraph, or made only of whitespace fails the pull. The messages are static: unlike the templates above, they cannot reference the component, commit, or request being viewed.

If a field is omitted or set to the empty string, the dashboards fall back to a built-in hint that quotes the exact bypass command for the gate, including the component and request number: [`/lunar bypass`](/docs/pr-comments#lunar-bypass) or `lunar policy bypass-pr` for the merge gate, and `lunar policy bypass-release` for the release gate.


# domains

Define the domains section of lunar-config.yml — hierarchical groupings that organize related components for ownership and policy targeting.

* `lunar-config.yml -> domains`
* Type: `object`
* Form:

  ```yaml
  domains:
    <domain-path>: <domain-object>
    <domain-path>: <domain-object>
    ...
  ```

Domains are used to group related components together. Domains are hierarchical and can contain other domains.

A domain's path is a string that uniquely identifies the domain. If a domain `bar` is within the domain `foo`, then the domain path is `foo.bar`.

Components under a domain receive the special tag `domain:<domain-name>` automatically. For example, components in the domain `foo.bar` will receive the tag `domain:foo.bar`. This tag can be used in [tag matching expressions](/configuration/lunar-config/on) to target components by domain.

Example domains defintion:

{% code title="lunar-config.yml" %}

```yaml
domains:
  saas-product:
    description: Acme's SaaS product
    owner: roberto@example.com
  saas-product.frontend:
    description: The frontend of Acme's SaaS product
    owner: jacqueline@example.com
  saas-product.frontend.ui-components:
    description: Common UI components for the frontend
    owner: jill@example.com
  saas-product.frontend.ui-common:
    description: Common UI code for the frontend
    owner: jane@example.com
  saas-product.frontend.ui-dashboard:
    description: The dashboard for the frontend
    owner: mary@example.com
  saas-product.frontend.ui-login:
    description: The login page for the frontend
    owner: jessica@example.com
  saas-product.backend:
    description: The backend of Acme's SaaS product
    owner: john@example.com
  saas-product.backend.auth:
    description: The authentication service for the backend
    owner: jesse@example.com
  saas-product.backend.rev-proxy:
    description: The reverse proxy for the backend
    owner: mike@example.com
  saas-product.backend.gateway:
    description: The gateway for the backend
    owner: larry@example.com
  widget-product:
    description: Acme's widget product
    owner: noah@example.com
  widget-product.frontend:
    description: The frontend of Acme's widget product
    owner: corey@example.com
  widget-product.data-processing:
    description: Data processing for Acme's widget product
    owner: ann@example.com
  common-infra:
    description: Common infrastructure
    owner: alice@example.com
    meta: # 🚧 Coming Soon
      "okta-team": "infra"
  common-infra.monitoring:
    description: Monitoring for common infrastructure
    owner: bob@example.com
  common-infra.logging:
    description: Logging for common infrastructure
    owner: todd@example.com
```

{% endcode %}

Domains are associated with components using the `domain` field in the [component definition](/configuration/lunar-config/components).

## Domain

* `lunar-config.yml -> domains.<domain-path>`
* Type: `object`
* Form:

  ```yaml
  description: <description>
  owner: <email>
  meta:                          # 🚧 Coming Soon
    <meta-key>: <meta-value>
    <meta-key>: <meta-value>
    ...
  ```

A domain is a group of related components. Domains are hierarchical and can contain other domains.

### `description`

* `lunar-config.yml -> domains.<domain-path>.description`
* Type: `string`
* Optional

A description of the domain.

### `owner`

* `lunar-config.yml -> domains.<domain-path>.owner`
* Type: `string`
* Optional

The email address of the owner of the domain.

### `meta` 🚧 Coming Soon

* `lunar-config.yml -> domains.<domain-path>.meta`
* Type: `object`
* Optional

A key-value store of arbitrary metadata for the domain. This metadata is not used by Lunar, but can be used by collectors and policies.


# components

Define the components section of lunar-config.yml — repositories or monorepo subdirectories that Lunar monitors with metadata and tags.

* `lunar-config.yml -> components`
* Type: `object`
* Form:

  ```yaml
  components:
    <component-name>: <component-object>
    <component-name>: <component-object>
    ...
  ```

Components are the individual units of code that are monitored by Lunar. They represent a complete software deliverable, such as a microservice, binary, or a library. Components can be an entire repository, or a subdirectory within a repository in the case of monorepos.

The name of a component is the repository URL or a pattern that matches multiple repositories. For example, `github.com/my-org/my-repo` or `github.com/my-org/*`.

On GitLab, the name includes the full namespace, so nested subgroups become part of it — `gitlab.com/my-group/platform/my-project`. A monorepo subdirectory is separated from the project path by GitLab's own `/-/` marker, which is what keeps the subdirectory distinguishable from another subgroup:

```yaml
components:
  gitlab.com/my-group/my-project:                     # a project
  gitlab.com/my-group/platform/my-project:            # a project in a subgroup
  gitlab.com/my-group/my-monorepo/-/services/api:     # a subdirectory of a monorepo
  gitlab.com/my-group/ui-*:                           # every project in my-group whose path starts with ui-
```

{% hint style="info" %}
A GitLab wildcard matches projects **directly inside** the named group — it does not descend into subgroups. `gitlab.com/my-group/*` will not match `gitlab.com/my-group/platform/api`; name the subgroup explicitly (`gitlab.com/my-group/platform/*`) to cover it.
{% endhint %}

Each component automatically receives the special tag `component:<component-id>`. For example, the component `github.com/my-org/my-repo` will receive the tag `component:github.com/my-org/my-repo`. This tag can be used in [tag matching expressions](/configuration/lunar-config/on) to target specific components.

Components can be defined here, in the Lunar configuration file, or in a separate file, `lunar.yml`, in the root of the component directory. Both definitions can co-exist, complementing each other (some components can be defined centrally in `lunar-config.yml`, while others can be defined via `lunar.yml`).

Example components definition:

{% code title="lunar-config.yml" %}

```yaml
components:
  github.com/my-org/my-repo:
    owner: jane@example.com
    domain: widget-product.frontend
    branch: prod
    tags: [go, backend, pii]
  github.com/my-org/my-monorepo/*:
    tags: [tier1]
  github.com/my-org/my-monorepo/proj1:
    owner: jacqueline@example.com
    domain: widget-product.data-processing
    tags: [java, backend]
    meta:
      "pagerduty-escalation-policy": "P1"
  github.com/my-org/ui-*:
    tags: [frontend]
  github.com/my-org/ui-components:
    owner: jack@example.com
    domain: ui-common
    tags: [react, typescript]
```

{% endcode %}

## Component

* `lunar-config.yml -> components.<component-name>`
* Type: `object`
* Form:

  ```yaml
  owner: <email>
  domain: <domain-path>
  branch: <branch-name>
  tags: [<tag>, <tag>, ...]
  ciPipelines: [<ci-pipeline>, <ci-pipeline>, ...]
  description: <description>
  paths: [<path>, <path>, ...]
  meta:
    <meta-key>: <meta-value>
    <meta-key>: <meta-value>
    ...
  ```

A single component is a unit of code that is monitored by Lunar. It represents a complete software deliverable, such as a microservice, binary, or a library.

As an alternative to defining components in `lunar-config.yml`, they may also be defined in a separate file, `lunar.yml`, in the root of the component directory. The fields in `lunar.yml` are the same as those in `lunar-config.yml -> components.<component-name>`. For more information, see the [lunar.yml](/configuration/lunar-yml) page.

### `owner`

* `lunar-config.yml -> components.<component-name>.owner`
* Type: `string`
* Optional

The email address of the owner of the component.

### `domain`

* `lunar-config.yml -> components.<component-name>.domain`
* Type: `string`
* Optional

A component can only belong to one domain. This field specifies the domain that the component belongs to.

To associate a component with a subdomain, specify the entire domain path. For example, to associate a component with the domain `bar`, which is under the domain `foo`, use the domain `foo.bar`.

If a domain is not specified, the component is placed in the `other` domain.

When a component is associated with a domain, it automatically gets the tag `domain:<domain-name>`. See [Tag Matching with `on`](/configuration/lunar-config/on) for more details on how to use these tags.

### `branch`

* `lunar-config.yml -> components.<component-name>.branch`
* Type: `string`
* Optional

The branch that the component is monitored on. If not specified, the default branch is used.

### `tags`

* `lunar-config.yml -> components.<component-name>.tags`
* Type: `array`
* Optional

A list of tags that to apply to the component. Tags can be used to associate collectors and policies to specific components.

### `ciPipelines`

* `lunar-config.yml -> components.<component-name>.ciPipelines`
* Type: `array`
* Optional - defaults to all CI pipelines in the repository

A list of CI pipeline names that are associated with the component. The CI pipelines are used to trigger the collection of data for the component. A single CI pipeline may be associated with multiple components at a time. If no CI pipelines are specified, then all CI pipelines within the repository are associated with the component.

This setting can be useful in monorepos, when certain CI pipelines might not be relevant to a specific component.

The pipeline name in GitHub Actions is the name of the GitHub Actions **workflow** — the `name:` field of the workflow file (exposed as `GITHUB_WORKFLOW`), not an individual job. In Buildkite, it is the name of the Buildkite pipeline.

### `description`

* `lunar-config.yml -> components.<component-name>.description`
* Type: `string`
* Optional

A description of the component.

### `paths`

* `lunar-config.yml -> components.<component-name>.paths`
* Type: `array`
* Optional

A list of paths within the repository that are associated with the component. This is useful in monorepo setups to specify which subdirectories belong to a given component.

Paths are matched against each changed file path. An entry ending in `*` is a **prefix match** — `services/api/*` matches any file at or under `services/api/` — while an entry without a trailing `*` must equal the changed path **exactly** (e.g. `go.mod`). Only a single trailing `*` is honored; full globs such as `**` are **not** supported. A component named after a monorepo subdirectory also gets an implicit `<subdir>/*` pattern automatically.

### `meta`

* `lunar-config.yml -> components.<component-name>.meta`
* Type: `object`
* Optional

A key-value store of arbitrary metadata for the component. Lunar does not interpret these values, but surfaces them to collectors and policies as the `LUNAR_COMPONENT_META` environment variable (a JSON object, set only when the component has metadata). Metadata can be declared here in `lunar-config.yml` or emitted by a cataloger.

## CI → component attribution

When the Lunar CI Tracer traces a CI run, it attributes the facts it collects to one or more components. In a monorepo, a single repository contains many components, so a CI run usually needs to map to a specific subdirectory component. Attribution is controlled by environment variables set in your CI workflow, together with the [`ciPipelines`](#cipipelines) and [`paths`](#paths) settings above.

### `LUNAR_COMPONENT`

Explicitly names the component(s) a CI run belongs to. Use a comma-separated list to attribute a single run to multiple components at once. Each entry is either:

* **repo-relative** — the subdirectory path, e.g. `services/api`; or
* **absolute** — the full component name, e.g. `github.com/my-org/my-monorepo/services/api`.

{% code title=".github/workflows/ci.yml" %}

```yaml
jobs:
  build-api:
    env:
      LUNAR_COMPONENT: "services/api,services/worker"
```

{% endcode %}

### `LUNAR_COMPONENT_INFER`

{% hint style="warning" %}
**Experimental.** Automatic inference is best-effort and has known cases where it can't resolve a component reliably — ambiguous or empty changed-path matches, working directories that don't map cleanly to a single component, and similar. For dependable attribution, prefer explicit `LUNAR_COMPONENT` or [`ciPipelines`](#cipipelines). Behavior may change.
{% endhint %}

Set to `true` to let the agent infer the component(s) automatically instead of naming them. Inference resolves in this order:

1. **Changed paths** — components whose [`paths`](#paths) intersect the files changed by the commit or pull request. (On pull requests the changed files come from the PR; on pushes, from the commit range.)
2. **Working directory** — if no changed paths match, the component whose subdirectory contains the traced command's working directory.

{% code title=".github/workflows/ci.yml" %}

```yaml
jobs:
  build:
    env:
      LUNAR_COMPONENT_INFER: "true"
```

{% endcode %}

### Precedence

`LUNAR_COMPONENT` (explicit) and [`ciPipelines`](#cipipelines) (workflow-name match) take precedence. `LUNAR_COMPONENT_INFER` is only consulted when neither of those selects a component. If nothing matches, the run is attributed to the repository-level component, when one is defined.


# collectors

Define the collectors section of lunar-config.yml — scripts that run on CI, code, or cron triggers to gather component metadata.

* `lunar-config.yml -> collectors`
* Type: `array`
* Form:

  ```yaml
  collectors:
    - <collector-object>
    - <collector-object>
    - ...
  ```

Collectors are used to collect live information from various sources to associate with individual components.

Example collectors definition:

{% code title="lunar-config.yml" %}

```yaml
collectors:
  - uses: github://third-party/some-collector@v1
    on: [auth, frontend]
    hook:
      type: ci-before-command
      pattern: ^go build.*
  - uses: ./my-collector
    on: ["domain:my-domain"]
    hook:
      type: ci-before-job
      pattern: .*
  - uses: ./my-collector
    on: ["domain:foo-product"]
    hook:
      type: code
  - uses: ./another-collector
    on: [go]
    hook:
      type: cron
      schedule: "0 2 * * *"
  - name: Hello world collector
    runBash: lunar collect '.hello' world
    on: [java]
    hook: 
      type: ci-after-command
      pattern: ^mvn install.*
  - name: Example script collector
    mainBash: ./my-script.sh
    on: [python]
    hook:
      type: cron
      schedule: "0 2 * * *"
```

{% endcode %}

## Collector

* `lunar-config.yml -> collectors.<collector-index>`
* Type: `object`
* Forms
  * Uses form (use an external collector plugin):

    ```yaml
    name: <collector-name>
    description: <description>
    uses: <collector-string>
    include: <include-array>
    exclude: <exclude-array>
    with:
      <input-name>: <input-value>
      ...
    on: <domain-array>
    runs_on: <runs-on-array>
    image: <docker-image>
    ```
  * Run form (define a collector inline):

    ```yaml
    name: <collector-name>
    description: <description>
    run<language>: <command-string>
    on: <domain-array>
    runs_on: <runs-on-array>
    image: <docker-image>
    hook: <hook-configuration>
    hooks:
      - <hook-configuration>
      - <hook-configuration>
      - ...
    ```
  * Main form (define a collector inline with a main file):

    ```yaml
    name: <collector-name>
    description: <description>
    main<language>: <main-file-path>
    on: <domain-array>
    runs_on: <runs-on-array>
    image: <docker-image>
    hook: <hook-configuration>
    hooks:
      - <hook-configuration>
      - <hook-configuration>
      - ...
    ```

Collectors are used to collect live information from various sources to associate with individual components. Collectors can be used to instrument CI/CD pipelines, run cron jobs, or execute arbitrary logic when code changes.

Collectors can either be imported (Uses form), defined as an inline command (Run form), or defined as a script to run (Main form). When a collector is defined inline (Run and Main forms), a hook must be specified to determine when the collector should run.

### `name`

* `lunar-config.yml -> collectors.<collector-index>.name`
* Type: `string`
* Required for Run and Main collector forms, Optional for Uses collector form

The `name` field is used to specify the name of the collector. If a name is not provided in the case of a collector plugin, the name from the collector plugin is used. The name must be unique within the configuration.

### `description`

* `lunar-config.yml -> collectors.<collector-index>.description`
* Type: `string`
* Optional

A description of the collector. This is used to describe the purpose of the collector in the Lunar UI and logs.

### `uses`

* `lunar-config.yml -> collectors.<collector-index>.uses`
* Type: `string`
* Forms
  * GitHub form: `github://<org>/<repo>@<version>`
  * GitLab form: `gitlab://<host>/<namespace>/<project>@<version>` (the host is required, including for `gitlab.com`)
  * Local form: `./<path-to-collector>`
* Required in Uses collector form

The `uses` field specifies an external (plugin) collector to use. The collector can be a third-party collector, or a local collector defined in a subdirectory. Browse the [30+ available integrations](https://earthly.dev/lunar/integrations/) to find collectors for your tools.

### `with`

* `lunar-config.yml -> collectors.<collector-index>.with`
* Type: `object`
* Optional

The `with` field specifies the inputs to pass to the collector plugin. The inputs are defined in the collector's configuration file. Input values are available to collector scripts as `LUNAR_VAR_*` environment variables.

Plugin authors can also reference inputs in their plugin YAML definitions using the `${{ inputs.NAME }}` syntax. This allows plugins to expose configurable fields -- such as hook parameters -- as explicit settings. See [collector plugins](/plugin-sdks/plugins/collector-plugins#inputs) for details.

### `include`

* `lunar-config.yml -> collectors.<collector-index>.include`
* Type: `array`
* Optional

The `include` field specifies which subcollectors to include from an imported collector plugin. When a collector is imported via `uses`, it may define (or import) multiple subcollectors. Use `include` to control which of those subcollectors are used.

If neither `include` nor `exclude` is specified, all subcollectors are included by default.

### `exclude`

* `lunar-config.yml -> collectors.<collector-index>.exclude`
* Type: `array`
* Optional

The `exclude` field specifies which subcollectors to exclude from an imported collector plugin. Use `exclude` when you want to include most subcollectors but skip a few specific ones.

If neither `include` nor `exclude` is specified, all subcollectors are included by default.

For example, if a collector called `go` includes subcollectors named `version`, `dependencies`, and `build-info`:

{% code title="lunar-config.yml" %}

```yaml
collectors:
  # Include only the version subcollector
  - uses: ./dir/go
    include: [version]

  # Include all except version
  - uses: ./dir/go
    exclude: [version]

  # Include version and dependencies only
  - uses: ./dir/go
    include: [version, dependencies]
```

{% endcode %}

### `run<language>`

* `lunar-config.yml -> collectors.<collector-index>.run<language>`
* Type: `string`
* Required in Run collector form

Defines the command to execute when the collector is invoked. Only `Bash` and `Python` are supported. So `runBash` and `runPython` are the only valid fields.

Running Bash supports [installing dependencies](/plugin-sdks/bash-sdk/dependencies).

#### `runBash`

* `lunar-config.yml -> collectors.<collector-index>.runBash`
* Type: `string`

The `runBash` field specifies the bash collector script to run.

#### `runPython`

* `lunar-config.yml -> collectors.<collector-index>.runPython`
* Type: `string`

The `runPython` field specifies the python collector script to run. Running Python supports [installing dependencies](/plugin-sdks/python-sdk/dependencies).

### `main<language>`

* `lunar-config.yml -> collectors.<collector-index>.main<language>`
* Type: `string`
* Required in Main collector form

Defines the main file path used to execute when the collector is invoked. Only `Bash` and `Python` are supported. So `mainBash` and `mainPython` are the only valid fields.

The file path is relative to the directory of the configuration file that declares it.

Running Bash supports [installing dependencies](/plugin-sdks/bash-sdk/dependencies).

#### `mainBash`

* `lunar-config.yml -> collectors.<collector-index>.mainBash`
* Type: `string`

The `mainBash` field specifies the path to the bash main file to run.

#### `mainPython`

* `lunar-config.yml -> collectors.<collector-index>.mainPython`
* Type: `string`

The `mainPython` field specifies the path to the python main file to run. Running Python supports [installing dependencies](/plugin-sdks/python-sdk/dependencies).

### `on`

* `lunar-config.yml -> collectors.<collector-index>.on`
* Type: `array`
* Required

The `on` field specifies the tags that the collector should be associated with. The collector will only run when the component has one or more of the specified tags.

For detailed documentation on tag matching syntax, including domain/component targeting, expressions, and cross-references to other collectors or policies, see [Tag Matching with `on`](/configuration/lunar-config/on).

### `runs_on`

* `lunar-config.yml -> collectors.<collector-index>.runs_on`
* Type: `array`
* Optional
* Default:
  * `[prs, default-branch]` if the collector has a non-`cron` hook
  * `[default-branch]` if the collector's only hook is `cron`

Specifies the contexts in which the collector should run. The available values are:

* `prs` - the collector will run on pull requests
* `default-branch` - the collector will run on the default branch

By default, collectors run in both contexts. To restrict a collector to only run on pull requests, use `runs_on: [prs]`. To restrict a collector to only run on the default branch, use `runs_on: [default-branch]`.

For a collector whose only hook is a [`cron` hook](/configuration/lunar-config/collector-hooks#cron), the default is `[default-branch]` and [pull-request runs](/configuration/lunar-config/collector-hooks#running-on-pull-requests) are opt-in.

### `hook`

* `lunar-config.yml -> collectors.<collector-index>.hook`
* Type: `object`
* One of `hook` or `hooks` is required in Run collector form

Using `hook` is equivalent to using a single hook in the `hooks` field. The `hook` field specifies when the collector should run.

For more information about hook definitions see the [hooks configuration page](/configuration/lunar-config/collector-hooks).

### `hooks`

* `lunar-config.yml -> collectors.<collector-index>.hooks`
* Type: `array`
* One of `hook` or `hooks` is required in Run collector form

The `hooks` field specifies when the collector should run.

For more information about hook definitions see the [hooks configuration page](/configuration/lunar-config/collector-hooks).

### `image`

* `lunar-config.yml -> collectors.<collector-index>.image`
* Type: `string`
* Optional

The `image` field specifies the Docker image to use when running the collector. When set, the collector runs inside a container instead of natively on the host.

Use the special value `native` to explicitly run the collector without a container, even when a default image has been configured.

Example:

{% code title="lunar-config.yml" %}

```yaml
collectors:
  # Run in a container
  - runBash: lunar collect .file-count "$(find . | wc -l)"
    image: earthly/lunar-scripts:1.0.0
    hook:
      type: code
    on: [my-tag]

  # Run natively (override any default image)
  - runBash: lunar collect .ci-info "$CI_JOB_ID"
    image: native
    hook:
      type: ci-before-command
      pattern: ^.*
    on: [my-tag]
```

{% endcode %}

For more information about default images and container execution, see [Images](/configuration/lunar-config/images).


# collectors/hooks

Configure collector hooks in lunar-config.yml — triggers like CI commands, code events, or cron schedules that determine when collectors run.

* `lunar-config.yml -> collectors.<collector-index>.hooks`
* `lunar-collector.yml -> collectors.<collector-index>.hooks`
* Type: `array`
* Form:

  ```yaml
  hooks:
    - <hook-configuration>
    - <hook-configuration>
    - ...
  ```

Hooks defines when a collector should run.

Example hooks definition:

```yaml
hooks:
  - type: ci-before-command
    binary:
      name: go
    args:
      - value: build
  - type: code
  - type: cron
    schedule: "0 2 * * *"
```

## Collector Hook

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>`
* `lunar-collector.yml -> collectors.<collector-index>.hooks.<hook-index>`
* Type: `object`
* Form:

  ```yaml
  type: <hook-type>
  <options>
  ```

A collector hook defines a trigger point for when a collector should run. Collectors can be triggered by various events such as code changes, CI pipeline events, or cron schedules.

Different hooks will cause the collector to execute in different contexts. For example, `ci-*` hooks will execute the collector in the context of the CI pipeline, while `code` and `cron` hooks will execute on a Lunar runner.

A hook has different configuration options depending on the type of event it is triggered by.

### `type`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.type`
* Type: `string`
* Required

The type of hook. The available values are:

* `ci-before-job` - triggers before a CI job
* `ci-after-job` - triggers after a CI job
* `ci-before-step` - triggers before a CI step
* `ci-after-step` - triggers after a CI step
* `ci-before-command` - triggers before a command is executed
* `ci-after-command` - triggers after a command is executed
* `code` - triggers when code changes
* `cron` - triggers on a schedule
* `after-json` **(beta)** - triggers once a component's collection has settled, if a Component JSON path **is present**
* `missing-json` **(beta)** - triggers once a component's collection has settled, if a Component JSON path **is absent**

## Hook Types

### `ci-before-job` / `ci-after-job`

* Form:

  ```yaml
  type: ci-before-job | ci-after-job
  pattern: <regex-pattern>
  ```

The `ci-before-job` type triggers the collector before a CI job is run. The `ci-after-job` type triggers the collector after a CI job is run. The collector will run if the job name matches the specified regex pattern.

If no pattern is specified, the collector will run before/after every job.

#### `pattern`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.pattern`
* Type: `string`
* Optional
* Default: `.*`

A regex pattern to match against the job name.

### `ci-before-step` / `ci-after-step`

* Form:

  ```yaml
  type: ci-before-step | ci-after-step
  pattern: <regex-pattern>
  ```

The `ci-before-step` type triggers the collector before a CI step is run. The `ci-after-step` type triggers the collector after a CI step is run. The collector will run if the step name matches the specified regex pattern.

If no pattern is specified, the collector will run before/after every step.

#### `pattern`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.pattern`
* Type: `string`
* Optional
* Default: `.*`

A regex pattern to match against the step name.

### `ci-before-command` / `ci-after-command`

* Forms:
  * Simple form:

    ```yaml
    type: ci-before-command | ci-after-command
    binary:
      name: <name-string>
    args:
      - flag: <flag-string>
        value: <value-string>
      - ...
    ```
  * Advanced form:

    ```yaml
    type: ci-before-command | ci-after-command
    binary:
      name[_pattern]: <name-string-or-pattern>
      dir[_pattern]: <dir-string-or-pattern>
      use_path_dirs: <boolean>
    args:
      - flag[_pattern]: <flag-string-or-pattern>
        value[_pattern]: <value-string-or-pattern>
      - ...
    args_pattern: <args-regex-pattern>
    envs:
      - name[_pattern]: <name-string-or-pattern>
        value[_pattern]: <value-string-or-pattern>
      - ...
    include_children_depth: <integer>
    max_process_depth: <integer>
    ```
  * Pattern form (deprecated):

    ```yaml
    type: ci-before-command | ci-after-command
    pattern: <regex-pattern>
    ```

The `ci-before-command` type triggers the collector before a command is run in the CI pipeline. The `ci-after-command` type triggers the collector after a command is run.

The command can be any process within the CI pipeline even if it is wrapped in scripts or called from other commands.

There are two forms for matching commands: the **Simple form** provides a straightforward way to match commands for common use cases, while the **Advanced form** provides additional options for fine-grained control. The Simple form is a subset of the Advanced form. The **Pattern form** is deprecated and may be removed in a future release.

For the hook to trigger, all specified matchers (`binary`, `args`, `envs`) must match. This is an AND operation. To implement OR logic (e.g., matching both `-f` and `--file` flags), use regex patterns with alternation (e.g., `flag_pattern: ^(-f|--file)$`).

#### Matching Limitations

The argument matching algorithm operates on raw argument strings and does not have semantic knowledge of the command being executed. Specifically:

* The `flag` + `value` construct matches consecutive arguments (e.g., `--file foo.txt`) or arguments joined with `=` (e.g., `--file=foo.txt`). It does not know whether a command's flag actually accepts a value.
* In ambiguous cases, incorrect matches may occur. For example, given `mycommand --file --verbose`, a matcher with `flag: --verbose` would match, even though `--verbose` might actually be the value of `--file` (if `--file` accepts any string as its value).
* Positional argument matchers match in order but cannot distinguish between a true positional argument and a flag's value. For example, `value: build` would match both `go build ./...` (where `build` is a subcommand) and `go run ./cmd/mycmd.go --type build` (where `build` is the value of `--type`).

For most common CLI tools and usage patterns, these limitations do not cause issues. However, be aware of edge cases when matching commands with unusual argument structures.

For advanced edge cases, use `args_pattern` instead of `args`.

#### `binary`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.binary`
* Type: `object`
* Optional

Specifies how to match the command's binary. All specified fields must match for the binary to be considered a match.

**`name[_pattern]`**

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.binary.name[_pattern]`
* Type: `string`
* Optional

Specifies how to match the binary name. Use `name` for an exact match, or `name_pattern` for a regex pattern. The two fields are mutually exclusive.

**`dir[_pattern]`**

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.binary.dir[_pattern]`
* Type: `string`
* Optional

Specifies how to match the binary's directory. Use `dir` for an exact match, or `dir_pattern` for a regex pattern. The two fields are mutually exclusive with each other and with `use_path_dirs`. If none of `dir`, `dir_pattern`, or `use_path_dirs` is provided, the hook matches any directory.

**`use_path_dirs` 🚧 Coming Soon**

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.binary.use_path_dirs`
* Type: `boolean`
* Optional

By default (if `dir`, `dir_pattern` or `use_path_dirs` are not provided), the hook matches any directory.

If this field is set to `true`, restricts matching to binaries located in directories that are present in the `PATH` environment variable. Mutually exclusive with `dir` and `dir_pattern`.

#### `args`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.args`
* Type: `array`
* Optional

An array of argument matchers. All specified matchers must match for the hook to trigger.

Each argument matcher can specify a flag and/or a value. The `flag` and `value` fields work together to match arguments in both space-separated form (`--foo bar`) and equals form (`--foo=bar`).

**Ordering rules:**

* Matchers with only `value` (or `value_pattern`) and no `flag` are positional arguments and must be defined in the order they appear in the command.
* Matchers with `flag` (or `flag_pattern`) can be defined in any order, regardless of where they appear in the actual command.

**`flag[_pattern]`**

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.args.<arg-index>.flag[_pattern]`
* Type: `string`
* Optional

Specifies how to match the flag name. Use `flag` for an exact match (e.g., `-t`, `--tag`), or `flag_pattern` for a regex pattern (e.g., `^(-f|--file)$` to match both short and long forms). The two fields are mutually exclusive. Flag matchers can be defined in any order.

If neither `flag` nor `flag_pattern` is provided, the matcher is treated as a positional argument and only the value is matched. Positional matchers must be defined in the order they appear in the command.

**Note:** Only use `flag` for actual flags (arguments starting with `-` or `--`). For positional arguments like subcommands (e.g., `get` in `kubectl get pod`), use only `value` without `flag`.

For boolean flags (e.g., `--verbose`), provide only `flag` (or `flag_pattern`) without `value` or `value_pattern`. See the `value[_pattern]` documentation below for details on how boolean flags are matched.

**`value[_pattern]`**

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.args.<arg-index>.value[_pattern]`
* Type: `string`
* Optional

Specifies how to match the argument value. Use `value` for an exact match, or `value_pattern` for a regex pattern. The two fields are mutually exclusive.

* If neither `value` nor `value_pattern` is provided, the matcher assumes this is a boolean flag (a flag that takes no value, such as `--verbose`). In this case, `--flag` matches, but `--flag=` and `--flag=anything` do not match. Note that `--flag anything` would match because `anything` is treated as a separate argument, not a value for `--flag`.
* If `value` is set to an empty string (`""`), matches arguments with an explicitly empty value (e.g., `--flag=`), but not boolean flags without a value (e.g., `--flag`).

#### `args_pattern`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.args_pattern`
* Type: `string`
* Optional

A regex pattern to match against all command arguments as a single space-concatenated string. This is an alternative to the `args` array for advanced matching scenarios where the structured `args` matchers are insufficient.

For example, if a command is invoked as `mycommand --flag value arg1 arg2`, the `args_pattern` would match against the string `--flag value arg1 arg2`.

The `args` array and `args_pattern` can be used together; both must match for the hook to trigger.

#### `envs`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.envs`
* Type: `array`
* Optional

An array of environment variable matchers. All specified matchers must match for the hook to trigger.

**`name[_pattern]`**

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.envs.<env-index>.name[_pattern]`
* Type: `string`
* Optional

Specifies how to match the environment variable name. Use `name` for an exact match, or `name_pattern` for a regex pattern. The two fields are mutually exclusive.

**`value[_pattern]`**

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.envs.<env-index>.value[_pattern]`
* Type: `string`
* Optional

Specifies how to match the environment variable value. Use `value` for an exact match, or `value_pattern` for a regex pattern. The two fields are mutually exclusive.

#### `max_process_depth` 🚧 Coming Soon

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.max_process_depth`
* Type: `integer`
* Optional
* Default: infinite

Defines the maximum depth of the process itself in the process tree for which the hook will trigger. For example, if set to `1`, the hook will only trigger on top-level processes in the CI/CD pipeline.

#### `include_children_depth` 🚧 Coming Soon

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.include_children_depth`
* Type: `integer`
* Optional
* Default: `0`

If set to a value greater than zero, the hook will also trigger on child processes of the matched command, up to the specified depth. By default (`0`), the hook only triggers on the matched command itself.

#### `pattern` (deprecated)

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.pattern`
* Type: `string`
* Required in Pattern form
* Deprecated

A regex pattern to match against the full command line. This form is deprecated; use the Simple or Advanced form instead.

#### Examples

Match `go build` commands:

```yaml
type: ci-before-command
binary:
  name: go
args:
  - value: build
```

Match `kubectl get pod` (positional arguments in order):

```yaml
type: ci-before-command
binary:
  name: kubectl
args:
  - value: get
  - value: pod
```

Match `docker build` with any tag:

```yaml
type: ci-before-command
binary:
  name: docker
args:
  - value: build
  - flag_pattern: ^(-t|--tag)$
    value_pattern: .*
```

Match `npm run`, `npm test`, or `npm build`:

```yaml
type: ci-before-command
binary:
  name: npm
args:
  - value_pattern: ^(run|test|build)$
```

Match `pytest` with verbose flag:

```yaml
type: ci-after-command
binary:
  name: pytest
args:
  - flag: --verbose
```

Match commands with either `-f` or `--file` flag:

```yaml
type: ci-before-command
binary:
  name: mycommand
args:
  - flag_pattern: ^(-f|--file)$
    value_pattern: .*\.txt$
```

Match any Python binary in a specific directory:

```yaml
type: ci-before-command
binary:
  name_pattern: python[0-9]*
  dir: /usr/local/bin
```

Match `go` commands resolved via PATH:

```yaml
type: ci-before-command
binary:
  name: go
  use_path_dirs: true
args:
  - value_pattern: ^(build|test|run)$
```

Match commands with specific environment variable:

```yaml
type: ci-before-command
binary:
  name: make
envs:
  - name: DEBUG
    value: "1"
```

Match using `args_pattern` for advanced argument matching:

```yaml
type: ci-before-command
binary:
  name: terraform
args_pattern: (plan|apply).*-var-file=.*production
```

Match top-level `make` commands and their immediate children:

```yaml
type: ci-before-command
binary:
  name: make
max_process_depth: 1
include_children_depth: 1
```

Match using pattern form (deprecated):

```yaml
type: ci-before-command
pattern: ^go build.*
```

### `code`

* Form:

  ```yaml
  type: code
  ```

The `code` type triggers the collector when the code of the component changes (i.e. there are new commits).

### `cron`

* Form:

  ```yaml
  type: cron
  clone-code: <boolean>
  schedule: <cron-schedule>
  ```

The `cron` type triggers the collector on a cron schedule. The collector will run according to the specified cron schedule.

#### `schedule`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.schedule`
* Type: `string`
* Required

A cron expression specifying when the collector should run (e.g., `"0 2 * * *"` for daily at 2am).

#### `clone-code`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.clone-code`
* Type: `boolean`
* Optional
* Default: `false`

If set to `true`, the collector will execute in the context of the code repository. This means that the collector will have access to a git clone of the code repository to interact with.

#### Running on pull requests

By default a cron collector runs only against the **default branch**: a collector whose only hook is `cron` defaults its [`runs_on`](/configuration/lunar-config/collectors#runs_on) to `[default-branch]`. To also run the collector against the head of every **open pull request** on each scheduled tick, opt in via `runs_on`:

```yaml
collectors:
  - name: nightly-rescan
    runs_on: [prs, default-branch]   # default branch + every open PR head
    runBash: ./rescan.sh
    hook:
      type: cron
      schedule: "0 * * * *"
      clone-code: true
```

* `runs_on: [default-branch]` — default branch only (the default for a cron-only collector).
* `runs_on: [prs]` — open pull request heads only.
* `runs_on: [prs, default-branch]` — both.

Each pull-request run is recorded against that PR's head commit, so the PR's policy results refresh on the schedule. For example, a scheduled dependency check can begin failing a pull request after a vulnerability is disclosed for a dependency it already uses — even though the PR's code hasn't changed. The default-branch run is unaffected.

A collector that declares a `cron` hook **and** a CI/code hook keeps the global `[prs, default-branch]` default, so the CI/code hook's pull-request coverage is preserved; set `runs_on` explicitly to change it.

### `after-json` / `missing-json`

{% hint style="warning" %}
**`after-json` and `missing-json` are beta.** Both are new and still subject to change — their behavior may shift in a future release.
{% endhint %}

* Forms:

  ```yaml
  type: after-json
  path: <json-path>
  ```

  ```yaml
  type: missing-json
  path: <json-path>
  ```

The `after-json` and `missing-json` types trigger a collector **once a component's collection has settled** for a commit — after every other collector and CI workflow for that `(component, commit)` has finished. This lets a collector depend on *data* — a [Component JSON](/docs/component-json) path — rather than on a specific named upstream collector, so it reacts to whatever produced (or failed to produce) that data.

The two are complements, distinguished by whether the declared `path` ended up populated:

* **`after-json` — path present (enrich).** Fires only when some collector or CI step wrote data at `path`. The collector runs and can act on that data, regardless of which upstream produced it.
* **`missing-json` — path absent (fallback).** Fires only when nothing wrote `path`. The collector runs so it can supply the data itself — a fallback that kicks in only when no other source did.

A path counts as **present** when the key exists — including when its value is `null` (matching the policy SDK, which treats `null` and missing as distinct). Only a path that never appears counts as **absent**.

To react in *both* cases, declare both hooks on the same collector (see the example below) — this is preferred over a single hook that guesses at intent.

The collector runs on an ephemeral runner and **does not get a git checkout** — there is no repository clone. It reads the component's accumulated data with `lunar component get-json` and writes results with `lunar collect`, and it can reach external resources such as container registries and APIs. Logic that needs the source tree belongs in a `code` or `ci-*` collector, not here.

#### `path`

* `lunar-config.yml -> collectors.<collector-index>.hooks.<hook-index>.path`
* Type: `string`
* Required

The Component JSON path the collector depends on, rooted at `.` (e.g. `".sbom"`, `".sca"`, `".containers.native.docker.cicd.cmds"`). It must start with `.`.

`path` is only valid on `after-json` / `missing-json` hooks — setting it on any other hook type is a configuration error.

#### Firing

* The collector fires **at most once per `(component, commit)` collection cycle**, once the component's collection has settled. Multiple writes to the same `path` do not re-trigger it.
* An `after-json` collector fires **only if `path` is present**; a `missing-json` collector fires **only if `path` is absent**. A collector that declares both hooks fires in either case.
* The component's checks stay `pending` until the fired collector's run finishes; policy then evaluates against the final Component JSON. A collector that fires but writes nothing still releases the gate — it can never leave checks pending forever.

#### Examples

Scan the container image the component just shipped, as soon as it is published — the `docker` collector records CI docker commands at `.containers.native.docker.cicd.cmds`, so the scan runs only when that path is present:

```yaml
collectors:
  - name: container-scan
    mainBash: scan.sh
    hook:
      type: after-json
      path: ".containers.native.docker.cicd.cmds"
```

```bash
#!/bin/bash
set -euo pipefail

# Resolve the most recently pushed image from the recorded docker commands —
# from Component JSON, not the repo (there is no checkout here). Simplified:
# take the first `docker push <ref>`.
IMAGE=$(lunar component get-json "$LUNAR_COMPONENT_ID" \
  | jq -r 'first((.containers.native.docker.cicd.cmds // [])[]?.cmd
                 | select(startswith("docker push ")) | ltrimstr("docker push "))
           // empty')
[ -n "$IMAGE" ] || { echo "No pushed image recorded — nothing to scan" >&2; exit 0; }

trivy image --quiet --format json "$IMAGE" | lunar collect -j ".container_scan" -
```

Generate an SBOM only when **no** collector produced one — a fallback keyed on the *absence* of `.sbom`:

```yaml
collectors:
  - name: sbom-fallback
    mainBash: sbom.sh
    hook:
      type: missing-json
      path: ".sbom"
```

```bash
#!/bin/bash
set -euo pipefail

# Only reached when .sbom is absent (missing-json fires on absence), so there is
# no need to re-check — just produce the SBOM the other collectors didn't.
syft scan "registry.example.com/$LUNAR_COMPONENT_NAME" -o cyclonedx-json \
  | lunar collect -j ".sbom" -
```

React to an SBOM whether or not one already exists — enrich it if present, generate it if absent — by declaring both hooks on one collector:

```yaml
collectors:
  - name: sbom-ensure
    mainBash: ensure.sh
    hook:
      - type: after-json    # fires when .sbom is present -> enrich
        path: ".sbom"
      - type: missing-json  # fires when .sbom is absent  -> generate
        path: ".sbom"
```


# initiatives

Define the initiatives section of lunar-config.yml — groupings of policies around shared goals or compliance requirements.

* `lunar-config.yml -> initiatives`
* Type: `array`
* Form:

  ```yaml
  initiatives:
    - name: <initiative-name>
      description: <initiative-description>
      owner: <initiative-owner>
      on: [<tag>, <tag>, ...]
    - name: <initiative-name>
      description: <initiative-description>
      owner: <initiative-owner>
      on: [<tag>, <tag>, ...]
    ...
  ```

Initiatives are used to group policies together around a specific goal or compliance requirement. They provide a way to organize policies and make them easier to manage and monitor.

Example initiatives definition:

{% code title="lunar-config.yml" %}

```yaml
initiatives:
  - name: security
    description: Security policies for all components
    owner: security-team@example.com
    on: [security, backend, frontend]
  - name: performance
    description: Performance optimization policies
    owner: platform-team@example.com
    on: [backend, api]
  - name: documentation
    description: Documentation compliance policies
    owner: docs-team@example.com
    on: [all]
```

{% endcode %}

## Initiative

* `lunar-config.yml -> initiatives.<initiative-index>`
* Type: `object`
* Form:

  ```yaml
  name: <initiative-name>
  description: <initiative-description>
  owner: <initiative-owner>
  on: [<tag>, <tag>, ...]
  ```

An initiative represents a collection of policies organized around a specific goal or purpose. Initiatives make it easier to manage and track related policies.

### `name`

* `lunar-config.yml -> initiatives.<initiative-index>.name`
* Type: `string`
* Required

The name field is used to specify the unique identifier for the initiative. The name `default` is reserved and cannot be used; a built-in "default" initiative is always created automatically for policies that don't specify an initiative.

### `description`

* `lunar-config.yml -> initiatives.<initiative-index>.description`
* Type: `string`
* Optional

The description field is used to provide a human-readable description of the initiative.

### `owner`

* `lunar-config.yml -> initiatives.<initiative-index>.owner`
* Type: `string`
* Optional

The owner field is used to specify the person or team responsible for the initiative. This is typically an email address.

### `on`

* `lunar-config.yml -> initiatives.<initiative-index>.on`
* Type: `array of strings`
* Optional

The `on` field specifies the tags that the initiative applies to. These tags are used to associate policies with the initiative.

For detailed documentation on tag matching syntax, including domain/component targeting, expressions, and cross-references to other collectors or policies, see [Tag Matching with `on`](/configuration/lunar-config/on).


# policies

Define the policies section of lunar-config.yml — rules that Lunar evaluates against components to enforce standards and check health.

* `lunar-config.yml -> policies`
* Type: `array`
* Form:

  ```yaml
  policies:
    - <policy-object>
    - <policy-object>
    - ...
  ```

Policies are used to define the rules that Lunar uses to evaluate the health of components.

Example policies definition:

{% code title="lunar-config.yml" %}

```yaml
policies:
  - uses: github://third-party/some-policy@v1
    on: [my-domain]
    enforcement: block-pr
  - uses: ./security-scanning
    on: [my-domain, another-domain]
    runs_on: [default-branch]
    enforcement: score
  - name: Collect code coverage information
    runPython: |
      from lunar_policy import Check, Path
      with Check("codecov-check", "Verify code coverage was collected") as check:
          check.assert_true(Path(".codecov.was_run"), "Code coverage data should be collected")
    on: [another-domain]
  - name: Should have unit tests
    mainPython: ./unit-tests.py
    on: [another-domain]
    enforcement: block-pr-and-release
```

{% endcode %}

## Policy

* `lunar-config.yml -> policies.<policy-index>`
* Type: `object`
* Forms:
  * Uses form:

    ```yaml
    name: <policy-name>
    uses: <policy-string>
    include: <include-array>
    exclude: <exclude-array>
    with:
      <input-name>: <input-value>
      ...
    on: <domain-array>
    runs_on: <runs-on-array>
    enforcement: <enforcement-level>
    initiative: <initiative-name>
    meta:
      <meta-key>: <meta-value>
      ...
    failureText: <failure-text-template>
    image: <docker-image>
    ```
  * Run form:

    ```yaml
    name: <policy-name>
    description: <policy-description>
    run<language>: <code-string>
    on: <domain-array>
    runs_on: <runs-on-array>
    enforcement: <enforcement-level>
    initiative: <initiative-name>
    image: <docker-image>
    ```
  * Main form:

    ```yaml
    name: <policy-name>
    description: <policy-description>
    main<language>: <main-file-path>
    on: <domain-array>
    runs_on: <runs-on-array>
    enforcement: <enforcement-level>
    initiative: <initiative-name>
    image: <docker-image>
    ```

Policies are used to define the rules that Lunar uses to evaluate the health of components. Policies are associated with domains and are automatically inherited by child domains.

### `name`

* `lunar-config.yml -> policies.<policy-index>.name`
* Type: `string`
* Required for Run and Main policy forms, Optional for Uses policy form

The `name` field is used to specify the name of the policy. If a name is not provided in the case of a policy plugin, the name from the policy plugin is used. The name must be unique within the configuration.

### `uses`

* `lunar-config.yml -> policies.<policy-index>.uses`
* Type `string`
* Forms
  * GitHub form: `github://<owner>/<repo>@<version>`
  * GitLab form: `gitlab://<host>/<namespace>/<project>@<version>`
  * Local form: `./<path-to-policy>`
* Required in Uses policy form

The `uses` field is used to import an external (plugin) policy from a GitHub or GitLab repository, or from a local file. The GitLab form requires an explicit host, including for `gitlab.com`. The policy is then associated with a domain. Browse the [100+ available guardrails](https://earthly.dev/lunar/guardrails/) to find policies for your standards.

### `with`

* `lunar-config.yml -> policies.<policy-index>.with`
* Type: `object`
* Optional

The `with` field specifies the inputs to pass to the policy plugin. The inputs are defined in the policy's configuration file. Input values are available to policy scripts via the `variable_or_default` function from the `lunar_policy` SDK.

Plugin authors can also reference inputs in their plugin YAML definitions using the `${{ inputs.NAME }}` syntax. This allows plugins to expose configurable fields as explicit settings. See [policy plugins](/plugin-sdks/plugins/policy-plugins#inputs) for details.

### `include`

* `lunar-config.yml -> policies.<policy-index>.include`
* Type: `array`
* Optional

The `include` field specifies which sub-policies to include from an imported policy plugin. When a policy is imported via `uses`, it may define (or import) multiple sub-policies. Use `include` to control which of those sub-policies are used.

If neither `include` nor `exclude` is specified, all sub-policies are included by default.

### `exclude`

* `lunar-config.yml -> policies.<policy-index>.exclude`
* Type: `array`
* Optional

The `exclude` field specifies which sub-policies to exclude from an imported policy plugin. Use `exclude` when you want to include most sub-policies but skip a few specific ones.

If neither `include` nor `exclude` is specified, all sub-policies are included by default.

For example, if a policy called `security` includes sub-policies named `vulnerability-scan`, `license-check`, and `dependency-audit`:

{% code title="lunar-config.yml" %}

```yaml
policies:
  # Include only the vulnerability-scan sub-policy
  - uses: ./dir/security
    include: [vulnerability-scan]

  # Include all except license-check
  - uses: ./dir/security
    exclude: [license-check]

  # Include vulnerability-scan and license-check only
  - uses: ./dir/security
    include: [vulnerability-scan, license-check]
```

{% endcode %}

### `description`

* `lunar-config.yml -> policies.<policy-index>.description`
* Type: `string`
* Optional

The `description` field is used to specify a description of the policy. If a description is not provided in the case of a policy plugin, the description from the policy plugin is used.

### `run<language>`

* `lunar-config.yml -> policies.<policy-index>.run<language>`
* Type: `string`
* Required in Run policy form

Defines the command to execute when the policy is invoked. Only `Python` is supported. So `runPython` is the only valid field.

Running Python supports [installing dependencies](/plugin-sdks/python-sdk/dependencies).

#### `runPython`

* `lunar-config.yml -> policies.<policy-index>.runPython`
* Type: `string`

The `runPython` field specifies the python policy script to run. Running Python supports [installing dependencies](/plugin-sdks/python-sdk/dependencies).

### `main<language>`

* `lunar-config.yml -> policies.<policy-index>.main<language>`
* Type: `string`
* Required in Main policy form

Defines the main file path used to execute when the policy is invoked. Only `Python` is supported. So `mainPython` is the only valid field.

The file path is relative to the directory of the configuration file that declares it.

Running Python supports [installing dependencies](/plugin-sdks/python-sdk/dependencies).

#### `mainPython`

* `lunar-config.yml -> policies.<policy-index>.mainPython`
* Type: `string`

The `mainPython` field specifies the path to the python main file to run. Running Python supports [installing dependencies](/plugin-sdks/python-sdk/dependencies).

### `on`

* `lunar-config.yml -> policies.<policy-index>.on`
* Type: `array`
* Required

The `on` field specifies the tags that the policy should be associated with. The policy will apply when the component has one or more of the specified tags.

For detailed documentation on tag matching syntax, including domain/component targeting, expressions, and cross-references to other collectors or policies, see [Tag Matching with `on`](/configuration/lunar-config/on).

### `runs_on`

* `lunar-config.yml -> policies.<policy-index>.runs_on`
* Type: `array`
* Default: `[prs, default-branch]`

Specifies the contexts in which the policy should run. The available values are:

* `prs` - the policy will run on pull requests
* `default-branch` - the policy will run on the default branch

By default, policies run in both contexts. To restrict a policy to only run on pull requests, use `runs_on: [prs]`. To restrict a policy to only run on the default branch, use `runs_on: [default-branch]`.

### `enforcement`

* `lunar-config.yml -> policies.<policy-index>.enforcement`
* Type: `string`. One of `draft`, `score`, `report-pr`, `block-pr`, `block-release`, `block-pr-and-release`
* Optional - defaults to `report-pr`

The `enforcement` field specifies the enforcement level of the policy. It determines how the policy affects the component.

The following enforcement levels are supported:

* `draft` - the policy is still under development and does not affect the score, and is not enforced or shown to application teams
* `score` - the checks under this policy contribute to the score of the component and are not reported in PRs. They still run on pull requests, subject to [`runs_on`](#runs_on), with their results visible on the pull request's dashboard — which makes `score` the level to trial a guardrail at, including one that only does anything in a PR, before application teams see it
* `report-pr` - the checks under this policy report the results in PRs, but do not block them
* `block-pr` - the checks under this policy block PRs from being merged
* `block-release` - the checks under this policy block releases, but not PRs. This level may be useful for checks that don't necessarily run in PRs due to performance reasons, but are nevertheless important to gate the release process.
* `block-pr-and-release` - the checks under this policy block both PRs and releases

{% hint style="info" %}
When `block-release` or `block-pr-and-release` levels are used, the Lunar CLI command `lunar policy ok-release <component> <git_sha>` will return a non-zero exit code of `1` if the associated policy is failing for the given component. This command may be used in CD or release pipelines to prevent a deployment to production, or a release package to be published.

When `block-pr` or `block-pr-and-release` levels are used, the Lunar CLI command `lunar policy ok-pr <component> <git_sha>` will return a non-zero exit code of `1` if the associated policy is failing for the given component. This command may be used wherever needed to block PR merges or prevent PR deployment pipelines to staging environments.
{% endhint %}

{% hint style="info" %}
**On GitLab Ultimate, a `block-pr` policy blocks the merge request directly** — Lunar reports the result as a status check that GitLab enforces, with a comment-driven override for authorized engineers. See the [merge gate](/install/git-platforms/gitlab#merge-gate). On GitLab Free and Premium, and on GitHub unless you mark Lunar's check required in branch protection, blocking is enforced through the `ok-pr` command above.
{% endhint %}

### `initiative`

* `lunar-config.yml -> policies.<policy-index>.initiative`
* Type: `string`
* Optional - defaults to `default`

The `initiative` field specifies the initiative that the policy belongs to. Initiatives are used to group related policies together for easier management and reporting. If not specified, the policy will be associated with the built-in "default" initiative.

For information on how to configure initiatives, see [initiatives](/configuration/lunar-config/initiatives).

### `meta`

* `lunar-config.yml -> policies.<policy-index>.meta`
* Type: `object` of `string` to `string`
* Optional

The `meta` field attaches your own key/value annotations to a policy. Lunar stores them and exposes them through the [SQL API](/sql-api/views/policies) so you can select checks by an identifier of your own, such as the control a policy enforces in your compliance framework:

{% code title="lunar-config.yml" %}

```yaml
policies:
  - uses: github://earthly/lunar-lib/policies/terraform@v1
    name: ebs-volume-enc
    include: [aws-ebs-volume-encryption]
    enforcement: block-pr
    meta:
      enforce_control: CONTROL123
```

{% endcode %}

```sql
SELECT cl.*
FROM checks_latest AS cl
JOIN policies AS p ON cl.policy_id = p.id
WHERE p.meta->>'enforce_control' = 'CONTROL123';
```

Keys and values are free-form; Lunar does not validate them against a schema. Two things to know before you pick a naming convention:

* **`meta` is never shown to a human.** It does not appear in pull request comments or in the dashboards. To put a control ID in front of a developer, render it with [`failureText`](#failuretext).
* **`meta` applies to every sub-policy in the entry it is written on.** A plugin bundles many sub-policies, and `include`/`exclude` select which ones run — but they all share the entry's `meta`. To map sub-policies to different values, import the plugin once per sub-policy, as the example above does with `include: [aws-ebs-volume-encryption]`.

`meta` is not a replacement for [initiatives](/configuration/lunar-config/initiatives). An initiative groups policies for reporting and can cover many controls; `meta` is a flat tagging dimension that sits alongside it.

### `failureText`

* `lunar-config.yml -> policies.<policy-index>.failureText`
* Type: `string`, a [Go text/template](https://pkg.go.dev/text/template)
* Optional - defaults to the built-in list of failing assertion messages

The `failureText` field replaces what a failing check writes into the pull request comment, so you can wrap a plugin's wording in your own prose and links without forking the policy:

{% code title="lunar-config.yml" %}

```yaml
policies:
  - uses: github://earthly/lunar-lib/policies/terraform@v1
    name: ebs-volume-enc
    include: [aws-ebs-volume-encryption]
    enforcement: block-pr
    meta:
      enforce_control: CONTROL123
    failureText: |
      Enforces control [{{ .meta.enforce_control }}](https://intranet.example.com/controls/{{ .meta.enforce_control }}):
      {{ .check.failure }}
```

{% endcode %}

The comment then reads:

```markdown
* ❌ `ebs-volume-enc.aws-ebs-volume-encryption` - EBS volumes must be encrypted at rest
  Enforces control [CONTROL123](https://intranet.example.com/controls/CONTROL123):
  * volume vol-a is unencrypted
  * volume vol-b is unencrypted
```

The template renders as Markdown, so links, emphasis, and lists all work. Three namespaces are available:

| Reference                                                | What it renders                                                                                            |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `{{ .check.failure }}`                                   | Every failing assertion as one bullet list, the same block Lunar writes by default. Position it as a unit. |
| `{{ range .check.failure_msgs }}{{ .message }}{{ end }}` | The failing assertions one at a time, for full control of the layout. Ordered as the policy asserted them  |
| `{{ .check.name }}`                                      | The check's name, qualified by its policy                                                                  |
| `{{ .meta.<key> }}`                                      | A value from the policy's [`meta`](#meta)                                                                  |
| `{{ .policy.name }}`, `{{ .policy.description }}`        | The policy's own name and description                                                                      |

{% hint style="warning" %}
**Setting `failureText` replaces the assertion messages; it does not add to them.** A template that references neither `{{ .check.failure }}` nor `{{ .check.failure_msgs }}` produces a comment that says the check failed without saying why. Include one of them unless you mean to drop the detail.
{% endhint %}

Two details worth knowing when you write one:

* **Hyphenated `meta` keys need `index`.** Go reads the hyphen in `{{ .meta.enforce-control }}` as subtraction, so a key like `enforce-control` has to be written `{{ index .meta "enforce-control" }}`. Underscored keys avoid it. Hyphens are fine on the SQL side either way.
* **A broken template falls back to the default.** An unknown key renders as empty text, and a template that fails to parse leaves the built-in assertion list in place. A typo costs you your wording, never the reason the check failed.

Like `meta`, `failureText` applies to every sub-policy in the entry it is written on.

### `image`

* `lunar-config.yml -> policies.<policy-index>.image`
* Type: `string`
* Optional

The `image` field specifies the Docker image to use when running the policy. When set, the policy runs inside a container instead of natively on the host.

Use the special value `native` to explicitly run the policy without a container, even when a default image has been configured.

Example:

{% code title="lunar-config.yml" %}

```yaml
policies:
  # Run in a container
  - uses: ./my-policy
    image: earthly/lunar-scripts:1.0.0
    on: [my-tag]

  # Run natively (override any default image)
  - mainPython: ./local-policy.py
    image: native
    on: [my-tag]
```

{% endcode %}

For more information about default images and container execution, see [Images](/configuration/lunar-config/images).


# on (tag matching)

Reference for the on field in lunar-config.yml — tag-matching expressions used by collectors, policies, and initiatives to target components.

The `on` field is used by collectors, policies, and initiatives to specify which components they apply to. It supports two forms: an array form for simple matching, and an expression form for complex logic.

## Array Form

The array form accepts a list of tags. A component matches if it has **any** of the specified tags (OR logic):

```yaml
on: [tag1, tag2, tag3]
```

## Expression Form

The expression form accepts a string with `AND`, `OR`, and `NOT` operators for complex matching logic:

```yaml
on: "tag1 OR tag2 AND NOT tag3"
```

Only the keywords `AND`, `OR`, and `NOT` are allowed as operators.

### Operator Precedence

Operators are evaluated with standard precedence:

1. `NOT` (highest precedence)
2. `AND`
3. `OR` (lowest precedence)

For example:

* `"a OR b AND c"` is evaluated as `"a OR (b AND c)"`
* `"a AND NOT b"` is evaluated as `"a AND (NOT b)"`

## Special Tags

### Domain Tags

Use `domain:<domain-name>` to match components in a specific domain (and its sub-domains):

```yaml
on: ["domain:engineering"]
```

To match a nested domain, use dot notation:

```yaml
on: ["domain:engineering.payments"]
```

In expression form:

```yaml
on: "domain:engineering AND NOT domain:engineering.experimental"
```

### Component Tags

Use `component:<component-id>` to match a specific component by its identifier:

```yaml
on: ["component:github.com/foo/bar"]
```

### Collector Reference

Use `collector:<collector-name>` to apply to the same components that another collector applies to:

```yaml
on: ["collector:foo"]
```

This is useful when you want one collector or policy to follow the same targeting rules as an existing collector.

### Policy Reference

Use `policy:<policy-name>` to apply to the same components that another policy applies to:

```yaml
on: ["policy:foo"]
```

## Combining Array and Expression Forms

The array form is equivalent to an OR expression. These two are identical:

```yaml
on: [tag1, tag2, tag3]
```

```yaml
on: "tag1 OR tag2 OR tag3"
```

Use the array form for simple OR-based matching, and the expression form when you need AND, NOT, or complex combinations.

## Examples

### All components in a domain

```yaml
on: ["domain:engineering"]
```

### Components with specific tags

```yaml
on: [backend, api]
```

### All components except those with a specific tag

```yaml
on: "NOT internal"
```

Matches all components that do **not** have the `internal` tag.

### All except a specific component

```yaml
on: "NOT component:github.com/foo/bar"
```

Matches all components except the one specified.

### Domain with exclusions

```yaml
on: "domain:engineering AND NOT domain:engineering.payments"
```

Matches all components in the `engineering` domain and its sub-domains, **except** those in the `engineering.payments` sub-domain.

### Include back after exclusion

```yaml
on: "NOT internal OR soc2"
```

This matches:

* All components that are **not** tagged `internal`, OR
* All components tagged `soc2`

This means an `internal` component that is **also** tagged `soc2` **will** be included.

### Components that must have multiple tags

```yaml
on: "production AND soc2"
```

Matches components that have **both** the `production` and `soc2` tags.

### Domain filtering with required tag

```yaml
on: "domain:engineering AND NOT domain:engineering.experimental AND production"
```

Matches components that:

1. Are in the `engineering` domain (or its sub-domains)
2. Are **not** in the `engineering.experimental` sub-domain
3. Have the `production` tag

### Complex targeting across domains

```yaml
on: "domain:engineering.payments OR domain:engineering.api AND soc2"
```

Matches components in `engineering.payments` (regardless of other tags) OR components in `engineering.api` that also have the `soc2` tag.


# Validating your config

Validate lunar-config.yml at commit/PR time — before merge — with a Hub-less dry run that runs the same checks the Hub does, plus a generated JSON Schema.

`lunar hub pull` validates configuration on the Hub, but by then the change has already merged. To catch mistakes **before** they merge, validate the config in CI on the pull request.

There are two complementary tools:

1. **`lunar hub pull --dry-run`** — the authoritative check, meant to run in CI on your pull requests. It runs the Hub's own load-and-validate steps against the same code (load the config, resolve every `uses:` plugin, validate the whole manifest, and resolve each component's default branch) and stops before applying anything — so it catches the mistakes a real `hub pull` would reject after merge, without a separate re-implementation that could drift.
2. **A JSON Schema** — generated from the manifest definition, for editor autocomplete and inline structural feedback while you type.

{% hint style="info" %}
The dry run needs **no Hub connection**. It does need **access to the Git platform your plugins live on** — a `LUNAR_GITHUB_TOKEN` or `LUNAR_GITLAB_TOKEN`, or a configured Hub used only as an auth source — to resolve `uses:` plugins, the same access the Hub itself uses to fetch them. This is what lets it catch plugin-resolution problems that a purely structural check would miss.
{% endhint %}

## `lunar hub pull --dry-run`

```bash
lunar hub pull --dry-run <repo>
```

`<repo>` is the config repository ref, e.g. `github://acme-corp/lunar@my-branch` or `gitlab://gitlab.com/acme-corp/lunar@my-branch`. The dry run clones it, loads `lunar-config.yml` (and any [`lunar-config.d/`](/configuration/lunar-config) fragments), resolves every `uses:` plugin, and runs the full manifest validation — required fields, enum values, hook/cron shape, [`on:`](/configuration/lunar-config/on) expressions, [domain](/configuration/lunar-config/domains) references, and whether each referenced plugin actually exists and resolves. It exits non-zero and prints the failure if anything is wrong, and **never** contacts the Hub or applies the manifest.

It runs the Hub's own `PullManifest` load-and-validate code — the `Fetch` pipeline plus the same component default-branch resolution the Hub performs before persisting — so a component pointing at a typo'd or inaccessible repo fails the dry run too, not just a real pull.

{% hint style="warning" %}
**One known gap:** the dry run does not install per-snippet dependencies (the `pip`/`npm` install the Hub runs for inline, non-image snippets), because that needs the runtime images and is too heavy for a CI gate. A dependency-install failure can therefore still surface only on a real `hub pull`. Everything else — structure, plugin resolution, and component branch resolution — is validated.
{% endhint %}

## Pre-merge validation in CI

Run the dry run on every pull request against your config repo. The example below is for **GitHub Actions** — it's provided as a starting point; adapt the CLI install and the invocation to whatever CI system you use ([Sync Config](/install/lunar-hub/self-hosted/sync-config#gitlab-ci) has the GitLab equivalent). It needs a token that can read the config repo and the plugin repos it references.

It intentionally runs on **all** pull requests rather than only when `lunar-config.yml` changes: the dry run resolves and validates every referenced plugin, including local `./`-path plugins in `collectors/`, `policies/`, or `catalogers/`, so a PR that edits one of those without touching `lunar-config.yml` is still checked. (A `paths:` filter would skip those.)

```yaml
name: Validate Lunar config

on:
  pull_request:

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - name: Install Lunar CLI
        run: |
          curl -fsSL -o lunar "https://github.com/earthly/lunar-dist/releases/latest/download/lunar-linux-amd64"
          chmod +x lunar
          sudo mv lunar /usr/local/bin/lunar
      - name: Dry-run the config
        env:
          LUNAR_GITHUB_TOKEN: ${{ secrets.LUNAR_GITHUB_TOKEN }}
        run: |
          lunar hub pull --dry-run "github://${{ github.repository }}@${{ github.event.pull_request.head.sha }}"
```

{% hint style="info" %}
Pin the CLI to a specific release (e.g. `.../releases/download/v1.2.3/lunar-linux-amd64`) instead of `latest` for reproducible CI. The token needs read access to the config repo and any private plugin repos it `uses:`.
{% endhint %}

## JSON Schema (editor autocomplete)

For editor autocomplete and inline structural validation as you edit, the CLI emits a [JSON Schema](https://json-schema.org/) (draft-07 format) for `lunar-config.yml`, generated from the same manifest definition the Hub uses:

```bash
lunar config schema > lunar-config.schema.json
```

Commit that file and point your editor at it. In VS Code, for example:

```jsonc
// .vscode/settings.json
{
  "yaml.schemas": {
    "./lunar-config.schema.json": ["lunar-config.yml", "lunar-config.d/*.yml"]
  }
}
```

{% hint style="warning" %}
The JSON Schema is a **structural aid only** — it checks unknown/misspelled keys, types, and enum values. It does **not** resolve `uses:` plugins or component branches (the dry run does that), so it can't tell you a plugin or repo doesn't exist. (Neither the schema nor the dry run installs per-snippet dependencies — see the dry-run note above.) Use `lunar hub pull --dry-run` as the authoritative check; treat the schema as editor feedback.

If you also validate against the schema in CI, use a **YAML 1.2** validator such as [`check-jsonschema`](https://check-jsonschema.readthedocs.io/). YAML 1.1 parsers (e.g. Python's `yaml.safe_load`) coerce bare `on:`, `yes:`, `no:`, and `off:` mapping keys into booleans, which spuriously fails schema validation on the `on:` field.
{% endhint %}


# lunar.yml

Reference for lunar.yml, the optional per-repository file that configures a single component's owner, domain, branch, tags, and CI pipelines.

{% hint style="info" %}
**Coming Soon** — This feature is not yet available.
{% endhint %}

* `lunar.yml`
* Type: YAML file
* Form:

  ```yaml
  version: 0

  owner: <email>
  domain: <domain-path>
  branch: <branch-name>
  tags: [<tag>, <tag>, ...]
  ciPipelines: [<ci-pipeline>, <ci-pipeline>, ...]
  ```

This page describes the configuration of a component via lunar.yml.

The file lunar.yml is optional and it can be used to define the configuration of a single component. If lunar.yml is not provided, the component configuration can be defined in the Lunar configuration file, [lunar-config.yml](/configuration/lunar-config). With the exception of the version field, the same fields in `lunar-config.yml -> components.<component-name>` are used in `lunar.yml`.

If a component is defined in both lunar.yml and lunar-config.yml, the settings are merged, and the configuration in lunar-config.yml takes precedence when a scalar field (`owner`, `domain`, `branch`) is defined in both places. The arrays (`tags`, `ciPipelines`) are appended to each other.

## `version`

* `lunar.yml -> version`
* Type: `string`
* Required

The version field is used to specify the version of the component configuration file. The current version is `0`.

## Other Fields

The other fields in `lunar.yml` are the same as those in `lunar-config.yml -> components.<component-name>`. For more information, see the [lunar-config.yml components](/configuration/lunar-config/components) page.


# Hub configuration


# Data Retention

What a Lunar Hub install deletes on its own, what it keeps for the life of the install, and the environment variables that change either.

What a Lunar Hub install deletes on its own, what it keeps forever, and which knobs change that.

## Do you need this page?

Read it if you operate a Hub and want to bound its storage growth, or if you need to state a retention window to satisfy a compliance requirement. If you run [Lunar Dedicated](/install/lunar-hub/dedicated/overview), Earthly sets these for your install and you can skip to [What no window covers](#what-no-window-covers) to see what retention does not reach.

This page is about data the Hub stores. It is not about the CI Tracer's local files, which the [CLI configuration reference](/install/ci-tracer/configuration-reference) covers.

## The short version

**Run history is capped by a retention window, but the feature is off until you turn it on.** Set `HUB_RETENTION_ENABLED=true` and the Hub ages out runs, policy results, and collection records older than 90 days. Leave it off, the default, and those tables grow with your commit volume and nothing removes them.

Queue rows, catalog history, and on-disk working files are capped separately and always have been.

## Run and trend retention

The customer-facing contract is two windows rather than a knob per table, because there are only two questions to answer: how far back can you see a run, and how far back do your trends go.

| Variable                        | Default | Governs                                                                                                                  |
| ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `HUB_RETENTION_ENABLED`         | `false` | Master switch. Nothing below deletes anything while this is off                                                          |
| `HUB_RETENTION_RUNS`            | `90d`   | Script runs, policy runs, policy assertions and rollups, collection records, catalog JSON items, merged collection blobs |
| `HUB_RETENTION_DERIVED`         | `90d`   | Materialized trends, component scores, the runs listing                                                                  |
| `HUB_RETENTION_CONFIG`          | `90d`   | Superseded manifest generations, and the components, domains and script definitions they defined                         |
| `HUB_RETENTION_CASCADE_ENABLED` | `false` | Whether to prune those generations at all                                                                                |

Windows are written in days — `90d`, `365d`. Go durations such as `2160h` still parse, but days are what these knobs mean and the Hub reports them back that way.

**The runs and derived windows must be equal in this version, and the Hub refuses to boot if they are not.** Setting a longer derived window looks like it should work and does not: two of the derived surfaces are rebuilt from the run tables, so they truncate themselves back to the runs window within a day, silently. Rejecting the config at boot is better than accepting one the Hub cannot deliver. Divergence becomes supported once those surfaces are fixed.

### Pruning superseded config

Every time a repository's config is published, the Hub writes a new manifest generation with its own components, domains, and script definitions. Those accumulate, and the components underneath them are the bulk of it.

`HUB_RETENTION_CASCADE_ENABLED` turns on removing them, and it is separate from `HUB_RETENTION_ENABLED` because it is a different kind of deletion. The runs window removes *history* — rows describing something that happened. This removes the *definitions* those rows point at, and it is the only part of retention that deletes a component.

**A generation is pruned when it is both superseded and older than `HUB_RETENTION_CONFIG`.** It has its own window rather than following the runs window, because config churn and run history are kept for different reasons.

**The newest generation for a repository is never pruned, at any age.** It is that repository's live configuration, so removing it would leave collectors and policies with nothing to run against. There is no setting that overrides this.

A generation whose runs have not aged out yet is skipped rather than pruned, and picked up on a later pass once the runs sweep has caught up.

**Retention is off by default and stays off through an upgrade.** Turning it on for the first time on an established install has years of accumulated rows to remove, so it deliberately does not do that in one pass. `HUB_RETENTION_BATCH_SIZE` (5000) and `HUB_RETENTION_MAX_BATCHES_PER_RUN` (100) bound each run, and `HUB_RETENTION_INTERVAL` (1h) sets how often it runs. The backlog drains over days. Watch the Hub logs for `budget_exhausted` — while it is true there is still a backlog, and when it goes false the install has reached steady state.

### Commits, pull requests, and repositories

Aged on their own clocks rather than the runs window, because they are not runs. Commits age on when they were committed, pull requests on when they were opened, and **repositories on when Lunar last synced them** — a repository is an identity, so "not seen in the window" is the condition that means it is gone, where "created long ago" only means it is old. An active repository is never removed for age.

A commit is removed only once nothing still names it. That check is wider than it looks: thirteen tables reference a commit by its SHA and only two by a foreign key, so the database alone would not stop a delete that orphans, for example, a component's current head pointer. All of them are checked before a commit is removed.

## Reclaiming deleted space

Deleting rows caps growth. It does not shrink the database file: the freed space becomes reusable by that table, and stays allocated.

Handing it back to the operating system takes a compaction pass, which holds an exclusive lock on the table while it runs. Readers block for the duration and then continue — it is a stall, not an error — but on a large table that stall is measured in minutes, so it is off by default and wants a maintenance window.

| Variable                            | Default              | Governs                                                       |
| ----------------------------------- | -------------------- | ------------------------------------------------------------- |
| `HUB_RETENTION_VACUUM_ENABLED`      | `false`              | Master switch for compaction                                  |
| `HUB_RETENTION_VACUUM_SCHEDULE`     | `0 3 * * *`          | When it checks, not when it acts                              |
| `HUB_RETENTION_VACUUM_MIN_BYTES`    | `1073741824` (1 GiB) | Minimum reclaimable space to justify the stall                |
| `HUB_RETENTION_VACUUM_MIN_RATIO`    | `0.2`                | Minimum fraction of the table that is reclaimable             |
| `HUB_RETENTION_VACUUM_LOCK_TIMEOUT` | `5s`                 | How long to wait for the lock before giving up until tomorrow |
| `HUB_RETENTION_VACUUM_TIMEOUT`      | `2h`                 | Cap on one compaction                                         |

**It runs nightly and should do nothing on almost every run.** A table is compacted only when it clears both thresholds, and only one table is compacted per night. Expect it to matter once, after retention first drains a large backlog, and rarely after that.

Leave the lock timeout short. The exclusive lock queues behind whatever transaction is already running, and every query that arrives meanwhile queues behind it, so a compaction that waits patiently is how a stall becomes an outage. Giving up and retrying tomorrow costs nothing.

## Queue retention

Terminal jobs are the highest-churn rows in the system. The Hub's queue and the script operator's queue each keep their own, and each is tuned separately.

| Variable                            | Default | Governs                                            |
| ----------------------------------- | ------- | -------------------------------------------------- |
| `HUB_QUEUE_COMPLETED_JOB_RETENTION` | `24h`   | Jobs that finished successfully                    |
| `HUB_QUEUE_CANCELLED_JOB_RETENTION` | `24h`   | Jobs cancelled before completing                   |
| `HUB_QUEUE_DISCARDED_JOB_RETENTION` | `168h`  | Jobs that exhausted their retries                  |
| `OPERATOR_COMPLETED_JOB_RETENTION`  | `24h`   | As above, for the script-execution queue           |
| `OPERATOR_CANCELLED_JOB_RETENTION`  | `24h`   | Script-execution jobs cancelled before completing  |
| `OPERATOR_DISCARDED_JOB_RETENTION`  | `168h`  | Script-execution jobs that exhausted their retries |

Nothing reads a terminal job. The durable record of a run is `hub.snippet_runs`, so shortening these windows loses no history you can query. Discarded jobs are the exception worth keeping longer, since they are failures someone may want to inspect.

**Reach for the operator knobs first on a large fleet.** `operator_queue.river_job` takes one row per script-execution batch and churns harder than any other table.

## Catalog history

`HUB_CATALOG_VERSION_RETENTION` (default `90d`) bounds how long captured catalog versions are kept for the SQL API `catalog` view. Set it to `0` to disable pruning.

Each version stores a complete catalog document, roughly 1 MB at 30,000 components, so the window multiplies against your capture rate rather than your component count alone. The newest version is always kept regardless of age, so the view never goes empty.

## On-disk working files

These govern the Hub's volume, not the database.

| Variable                         | Default   | Governs                                    |
| -------------------------------- | --------- | ------------------------------------------ |
| `HUB_INSTALL_FILE_MAX_AGE_DAYS`  | `10`      | Manifest directories, pruned by age        |
| `HUB_INSTALL_FILE_MAX_DISK_SIZE` | `0` (off) | The same directories, pruned by total size |
| `HUB_BUNDLE_MAX_AGE`             | `2h`      | Policy bundles staged for execution        |
| `HUB_BUNDLE_NO_DELETE`           | `false`   | Keeps bundles for debugging                |

The Hub writes a directory per manifest generation, so on a busy config this is the volume's fastest-growing tenant. The size arm is off by default because a byte cap only makes sense against a known volume size. Set it if your volume is small enough that age alone could still fill it.

`HUB_BUNDLE_MAX_AGE` covers a scratch directory rather than the persistent volume, so it is rarely the knob you want.

## Script pods

Pods are reaped by the operator on a separate set of timers: `OPERATOR_TERMINAL_POD_GRACE_PERIOD` (5m), `OPERATOR_MAX_POD_AGE` (12h), `OPERATOR_RUNNING_POD_TIMEOUT` (6h), and `OPERATOR_PENDING_POD_TIMEOUT` (10m). `OPERATOR_RETAIN_FAILED_PODS` keeps failed pods for inspection and is off by default.

## Object storage

**Lunar never deletes from your buckets.** Run logs and run-bundle archives stay until something outside Lunar removes them. Nothing on this page changes that: the windows above govern database rows, not objects.

On a self-hosted install, set S3 lifecycle rules yourself, and check them against your own retention obligations first, since both buckets can contain credentials, script source, or PII surfaced from CI runs. The [self-hosted prerequisites](/install/lunar-hub/self-hosted/prerequisites#step-4-provision-s3-compatible-object-storage) cover the bucket layout. On [Lunar Dedicated](/install/lunar-hub/dedicated/overview) the buckets live in your own account, so the rules are still yours to set — agree them with Earthly during setup rather than assuming either side has.

## What no window covers

`HUB_RETENTION_RUNS` reaches the run tables and their dependants. These are outside it, at any setting:

* The newest manifest generation for each repository, and everything under it

Everything else is covered, though not all of it by the runs window: components, domains, and script definitions go with [the generation that defined them](#pruning-superseded-config) under `HUB_RETENTION_CONFIG`, and commits, pull requests, and repositories have [their own clocks](#commits-pull-requests-and-repositories). Both need `HUB_RETENTION_CASCADE_ENABLED`.

### What `0` means

**`0` is not one sentinel.** It means different things to different knobs on this page, and several of them mean the opposite of what you would expect.

| Knob                                             | `0` means                                                                                                                                                    |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `HUB_RETENTION_RUNS` / `_DERIVED`                | Keep forever. Retention is disabled                                                                                                                          |
| `HUB_RETENTION_CONFIG`                           | Keep every superseded generation                                                                                                                             |
| `HUB_INSTALL_FILE_MAX_AGE_DAYS`                  | Keep forever. The pruner returns early                                                                                                                       |
| `HUB_CATALOG_VERSION_RETENTION`                  | Keep forever. Pruning is disabled                                                                                                                            |
| `HUB_QUEUE_*` / `OPERATOR_*` job retention       | **River's own default** — 24h, 24h, 168h. Not forever                                                                                                        |
| `HUB_BUNDLE_MAX_AGE`                             | **Delete every bundle on the next pass**, including one staged for execution                                                                                 |
| Pod timeouts (`OPERATOR_*`)                      | **Reap immediately**                                                                                                                                         |
| `HUB_RETENTION_VACUUM_LOCK_TIMEOUT` / `_TIMEOUT` | **Rejected at startup.** Not a sentinel — to Postgres a zero `lock_timeout` means wait forever, which is the behaviour the compaction section argues against |

The queue knobs are the trap worth naming, because the advice above is to keep discarded jobs longer than the rest: setting `HUB_QUEUE_DISCARDED_JOB_RETENTION=0` to keep them indefinitely silently gets you seven days. **To keep terminal jobs forever, set `-1ns`.** River's infinite sentinel is `-1`, and the value is parsed as a duration, so a bare `-1` is rejected for having no unit.

To keep bundles, use `HUB_BUNDLE_NO_DELETE=true` rather than a zero age.

## Sizing

Two things drive Hub database growth, and only one of them scales with the size of your fleet.

**Run history scales with commit volume, bounded by the retention window.** A component that never changes costs almost nothing; one receiving fifty commits a day writes runs fifty times a day. Estimate from your commit and merge rate over one window, not from your component count. With retention off, drop the window and the figure has no ceiling.

**Catalog history scales with capture rate and component count together**, at roughly 1 MB per captured version at 30,000 components.

The queue tables are bounded by the retention windows above and reach a steady state. If they do not, that is a backlog rather than a retention problem, and the queue-depth metrics on the Hub's `/metrics` endpoint will show it.

## Related

* [Day 2 Operations](/install/lunar-hub/self-hosted/day-2-operations) — upgrades, secret rotation, backup, and the diagnostics bundle
* [Prerequisites](/install/lunar-hub/self-hosted/prerequisites) — database and bucket sizing before install


# Plugins configuration

Overview of the three Lunar plugin types — catalogers, collectors, and policies — that extend the platform's capabilities.

Lunar supports three types of plugins:

## Plugin Types

### Catalogers

Catalogers discover and catalog components in your system. They help Lunar understand your architecture by identifying components and their hierarchy.

👉 [Learn more about Cataloger Plugins](/plugin-sdks/plugins/cataloger-plugins)

### Collectors

Collectors gather metrics and data from your components.

👉 [Learn more about Collector Plugins](/plugin-sdks/plugins/collector-plugins)

### Policies

Policies define rules and standards for your components.

👉 [Learn more about Policy Plugins](/plugin-sdks/plugins/policy-plugins)


# lunar-cataloger.yml

Reference for lunar-cataloger.yml, the manifest that defines a cataloger plugin used to sync software catalog information from external systems.

* `lunar-cataloger.yml`
* Type: YAML file
* Form:

  ```yaml
  version: 0

  name: <cataloger-name>
  description: <cataloger-description>
  author: <author-name>

  default_image: <default-image>

  catalogers:
    - <cataloger-object>
    - <cataloger-object>
    - ...

  inputs:
    <input-name>:
      description: <input-description>
      default: <input-default-value>
  ```

This page describes the configuration of a cataloger plugin. Cataloger plugins are used to synchronize software catalog information from external systems. Cataloger plugins can be imported from the Lunar configuration file, `lunar-config.yml` via the `uses` cataloger form.

Cataloger plugins can be defined either in a separate repository or in the same repository as the Lunar configuration, in a dedicated directory. Either way, the cataloger plugin must contain a `lunar-cataloger.yml` file in the root of the repository or directory. This file is used to configure the behavior of the cataloger plugin.

When using catalogers in the Main or Run forms, you can also install dependencies. See [installing dependencies in the Bash SDK](/plugin-sdks/bash-sdk/dependencies) for more details.

## `version`

* `lunar-cataloger.yml -> version`
* Type: `numeric`
* Required

The version field is used to specify the version of the cataloger configuration file. The current version is `0`.

## `name`

* `lunar-cataloger.yml -> name`
* Type: `string`
* Required

The name field is used to specify the name of the cataloger.

## `description`

* `lunar-cataloger.yml -> description`
* Type: `string`
* Optional

The description field is used to specify a description of the cataloger.

## `author`

* `lunar-cataloger.yml -> author`
* Type: `string`
* Optional

The author field is used to specify the author of the cataloger.

## `default_image`

* `lunar-cataloger.yml -> default_image`
* Type: `string`
* Optional

The default Docker image to use for all catalogers in this plugin. This overrides the global `default_image_catalogers` setting from `lunar-config.yml`.

Individual catalogers can still override this plugin default using the `image` field. Use the special value `native` to explicitly run without a container.

For more information about default images and container execution, see [Images](/configuration/lunar-config/images).

## `catalogers`

* `lunar-cataloger.yml -> catalogers`
* Type: `array`
* Required

The catalogers field is used to specify the configuration of the cataloger. The format of a cataloger is the same as in `lunar-config.yml`. To learn more about the configuration of a cataloger, see the [catalogers](/configuration/lunar-config/catalogers) page.

**Note:** When consumers import this plugin via `uses`, they can selectively include or exclude specific catalogers using the [`include`](/configuration/lunar-config/catalogers#include) field. This allows consumers to use only the catalogers they need from a plugin that defines multiple catalogers.

## `inputs`

* `lunar-cataloger.yml -> inputs`
* Type: `object`
* Optional

The inputs field is used to specify the inputs required by the cataloger. Each input is defined as a key-value pair, where the key is the input name.

Inputs are passed to the cataloger when invoked as environment variables with the prefix `LUNAR_VAR_` and the input name in uppercase. For example, an input named `api_url` is accessible as `$LUNAR_VAR_API_URL`.

Inputs can also be referenced in the plugin YAML definition itself using the `${{ inputs.NAME }}` syntax. This allows plugin authors to expose configurable fields as explicit settings. For example:

{% code title="lunar-cataloger.yml" %}

```yaml
inputs:
  schedule:
    description: Cron schedule for syncing
    default: "0 2 * * *"

catalogers:
  - name: org-sync
    mainBash: ./sync.sh
    hook:
      type: cron
      schedule: "${{ inputs.schedule }}"
```

{% endcode %}

When a consumer imports this plugin and passes `with: { schedule: "*/10 * * * *" }`, the variable is substituted before the plugin is processed. Substitution works in any string field of the plugin's snippet definitions.

### `description`

* `lunar-cataloger.yml -> inputs.<input-name>.description`
* Type: `string`
* Required

The description field is used to specify a description of the input.

### `default`

* `lunar-cataloger.yml -> inputs.<input-name>.default`
* Type: `string`
* Optional

The default field is used to specify the default value of the input. If no default value is specified, the input is required.


# lunar-collector.yml

Reference for lunar-collector.yml, the manifest that defines a collector plugin used to gather live information about components from various sources.

* `lunar-collector.yml`
* Type: YAML file
* Form:

  ```yaml
  version: 0

  name: <collector-name>
  description: <collector-description>
  author: <author-name>

  default_image: <default-image>
  default_image_ci_collectors: <default-image-ci>
  default_image_non_ci_collectors: <default-image-non-ci>

  collectors:
    - <collector-configuration>
    - <collector-configuration>
    - ...

  inputs:
    <input-name>:
      description: <input-description>
      default: <default-value>
    ...
  ```

This page describes the configuration of a collector plugin. Collector plugins are used to collect live information from various sources to associate with individual components. Collector plugins can be imported from the Lunar configuration file, `lunar-config.yml` via the `uses` collector form.

Collector plugins can be defined either in a separate repository or in the same repository as the Lunar configuration, in a dedicated directory. Either way, the collector plugin must contain a `lunar-collector.yml` file in the root of the repository or directory. This file is used to configure the behavior of the collector plugin.

When using collectors in the Main or Run forms, you can also install dependencies. See [installing dependencies in the Bash SDK](/plugin-sdks/bash-sdk/dependencies) or [installing dependencies in the Python SDK](/plugin-sdks/python-sdk/dependencies) for more details.

## `version`

* `lunar-collector.yml -> version`
* Type: `numeric`
* Required

The version field is used to specify the version of the collector configuration file. The current version is `0`.

## `name`

* `lunar-collector.yml -> name`
* Type: `string`
* Required

The name field is used to specify the name of the collector.

## `description`

* `lunar-collector.yml -> description`
* Type: `string`
* Optional

The description field is used to specify a description of the collector.

## `author`

* `lunar-collector.yml -> author`
* Type: `string`
* Optional

The author field is used to specify the author of the collector.

## Default Images

Collector plugins can define default Docker images that override the global defaults from `lunar-config.yml`. These settings apply to all collectors defined within the plugin.

### `default_image`

* `lunar-collector.yml -> default_image`
* Type: `string`
* Optional

The default image to use for all collectors in this plugin. Overrides the global `default_image` setting.

### `default_image_ci_collectors`

* `lunar-collector.yml -> default_image_ci_collectors`
* Type: `string`
* Optional

The default image for CI collectors (hooks: `ci-before-command`, `ci-after-command`, `ci-before-job`, `ci-after-job`). Overrides the global `default_image_ci_collectors` setting.

### `default_image_non_ci_collectors`

* `lunar-collector.yml -> default_image_non_ci_collectors`
* Type: `string`
* Optional

The default image for non-CI collectors (hooks: `code`, `cron`, `repo`, `after-json`, `missing-json`). Overrides the global `default_image_non_ci_collectors` setting.

Individual collectors can still override these plugin defaults using the `image` field.

For more information about default images and container execution, see [Images](/configuration/lunar-config/images).

## `collectors`

* `lunar-collector.yml -> collectors`
* Type: `array`
* Required

The collectors field is used to specify the configuration of the collector. The format of a collector is the same as in `lunar-config.yml`, except that the `on` field is not allowed. To learn more about the configuration of a collector, see the [collectors](/configuration/lunar-config/collectors) page.

**Note:** When consumers import this plugin via `uses`, they can selectively include or exclude specific collectors using the [`include`](/configuration/lunar-config/collectors#include) field. This allows consumers to use only the collectors they need from a plugin that defines multiple collectors.

## `inputs`

* `lunar-collector.yml -> inputs`
* Type: `object`
* Optional

The inputs field is used to specify the inputs required by the collector. Each input is defined as a key-value pair, where the key is the input name.

Inputs are passed to the collector when invoked as environment variables with the prefix `LUNAR_VAR_` and the input name in uppercase. For example, an input named `api_url` is accessible as `$LUNAR_VAR_API_URL`.

Inputs can also be referenced in the plugin YAML definition itself using the `${{ inputs.NAME }}` syntax. This allows plugin authors to expose configurable fields as explicit settings. For example:

{% code title="lunar-collector.yml" %}

```yaml
inputs:
  binary_name:
    description: Binary name to match
    default: "go"

collectors:
  - name: check-binary
    runBash: echo "checking"
    hook:
      type: code
      binary:
        name: "${{ inputs.binary_name }}"
```

{% endcode %}

When a consumer imports this plugin and passes `with: { binary_name: "python3" }`, the variable is substituted before the plugin is processed. Substitution works in any string field of the plugin's snippet definitions.

### `description`

* `lunar-collector.yml -> inputs.<input-name>.description`
* Type: `string`
* Required

The description field is used to specify a description of the input.

### `default`

* `lunar-collector.yml -> inputs.<input-name>.default`
* Type: `string`
* Optional

The default field is used to specify the default value of the input. If no default value is specified, the input is required.


# lunar-policy.yml

Reference for lunar-policy.yml, the manifest that defines a policy plugin used to evaluate the health of components against rules.

* `lunar-policy.yml`
* Type: YAML file
* Form:

  ```yaml
  version: 0

  name: <policy-name>
  description: <policy-description>
  author: <author-name>

  default_image: <default-image>

  policies:
    - <policy-configuration>
    - <policy-configuration>
    - ...

  inputs:
    <input-name>:
      description: <input-description>
      default: <default-value>
    ...
  ```

This page describes the configuration of a policy plugin. Policy plugins are used to define the rules that Lunar uses to evaluate the health of components. Policy plugins can be imported from the Lunar configuration file, `lunar-config.yml` via the `uses` policy form.

Policy plugins can be defined either in a separate repository or in the same repository as the Lunar configuration, in a dedicated directory. Either way, the policy plugin must contain a `lunar-policy.yml` file in the root of the repository or directory. This file is used to configure the behavior of the policy plugin.

When using policies in the Main or Run forms, you can also install dependencies. See [installing dependencies in the Python SDK](/plugin-sdks/python-sdk/dependencies) for more details.

## `version`

* `lunar-policy.yml -> version`
* Type: `numeric`
* Required

The version field is used to specify the version of the policy configuration file. The current version is `0`.

## `name`

* `lunar-policy.yml -> name`
* Type: `string`
* Required

The name field is used to specify the name of the policy.

## `description`

* `lunar-policy.yml -> description`
* Type: `string`
* Optional

The description field is used to specify a description of the policy.

## `author`

* `lunar-policy.yml -> author`
* Type: `string`
* Optional

The author field is used to specify the author of the policy.

## `default_image`

* `lunar-policy.yml -> default_image`
* Type: `string`
* Optional

The default Docker image to use for all policies in this plugin. This overrides the global `default_image_policies` setting from `lunar-config.yml`.

Individual policies can still override this plugin default using the `image` field. Use the special value `native` to explicitly run without a container.

For more information about default images and container execution, see [Images](/configuration/lunar-config/images).

## `policies`

* `lunar-policy.yml -> policies`
* Type: `array`
* Required

The policies field is used to specify the configuration of the policy. The format of a policy is the same as in `lunar-config.yml`, except that the `on` and `enforcement` fields are not allowed (these are configured in `lunar-config.yml` only). To learn more about the configuration of a policy, see the [policies](/configuration/lunar-config/policies) page.

**Note:** When consumers import this plugin via `uses`, they can selectively include or exclude specific policies using the [`include`](/configuration/lunar-config/policies#include) field. This allows consumers to use only the policies they need from a plugin that defines multiple policies.

## `inputs`

* `lunar-policy.yml -> inputs`
* Type: `object`
* Optional

The inputs field is used to specify the inputs that the policy requires. Each input is defined as a key-value pair, where the key is the input name.

Inputs are accessed in policies using the `variable_or_default` function from the `lunar_policy` SDK. For example, an input named `threshold` is accessible as `variable_or_default("threshold", "10")` where the second argument is the fallback default value.

Inputs can also be referenced in the plugin YAML definition itself using the `${{ inputs.NAME }}` syntax. This allows plugin authors to expose configurable fields as explicit settings. For example:

{% code title="lunar-policy.yml" %}

```yaml
inputs:
  threshold:
    description: Score threshold
    default: "80"

policies:
  - name: verify-score
    description: "Verify score meets threshold of ${{ inputs.threshold }}"
    mainPython: ./main.py
```

{% endcode %}

When a consumer imports this plugin and passes `with: { threshold: "95" }`, the variable is substituted before the plugin is processed. Substitution works in any string field of the plugin's snippet definitions.

### `description`

* `lunar-policy.yml -> inputs.<input-name>.description`
* Type: `string`
* Required

The description field is used to specify a description of the input.

### `default`

* `lunar-policy.yml -> inputs.<input-name>.default`
* Type: `string`
* Optional

The default field is used to specify the default value of the input. If no default value is specified, the input is required.


# Bash SDK

Overview of the Lunar Bash SDK for building custom catalogers and collectors with shell scripts.

The Bash SDK allows you to create Lunar plugins using Bash scripting. This SDK is particularly useful for building custom catalogers and collectors.

## SDK Components

Learn more about the specific plugin types you can create with the Bash SDK:

* [Cataloger](/plugin-sdks/bash-sdk/cataloger) - Create custom catalogers to identify components in your codebase
* [Collector](/plugin-sdks/bash-sdk/collector) - Build collectors to gather metadata about your components


# Installing dependencies

How to install dependencies for Bash collectors and catalogers using a custom Docker image or an install.sh script.

If your collector or cataloger requires dependencies, you have two options depending on your execution environment:

1. **Custom Docker image** - Bake dependencies into your image for containerized execution
2. **Install script** - Use `install.sh` for native execution

## Custom Docker Image

When running in containers, create a custom Docker image with all dependencies pre-installed. This provides faster startup times, reproducible builds, and eliminates network dependencies at runtime.

Create a Dockerfile that inherits from the official `earthly/lunar-scripts` image:

{% code title="Dockerfile" %}

```dockerfile
FROM earthly/lunar-scripts:1.0.0

# Install system dependencies
RUN apt-get update && apt-get install -y jq curl && rm -rf /var/lib/apt/lists/*

# Install additional tools
RUN curl -L "https://github.com/keilerkonzept/dockerfile-json/releases/download/v1.2.2/dockerfile-json_Linux_x86_64.tar.gz" | tar xz \
    && mv dockerfile-json /usr/local/bin/
```

{% endcode %}

Then configure your plugin or `lunar-config.yml` to use this image:

```yaml
image: my-org/my-custom-image:v1.0
```

For more details on image configuration, see [Images](/configuration/lunar-config/images).

## Install Script for Native Execution

When running with `image: native` (no container), you can install dependencies using an `install.sh` script in the same directory as your `lunar-config.yml`, `lunar-collector.yml` or `lunar-cataloger.yml` file.

This install script is executed only once in each environment, before the collector or cataloger is run.

### Example

```bash
#!/bin/bash
curl -L "https://github.com/keilerkonzept/dockerfile-json/releases/download/v1.2.2/dockerfile-json_Linux_x86_64.tar.gz" | tar xz
mv dockerfile-json "$LUNAR_BIN_DIR/"
```

### Installation path

If you need to install a binary that will be used in the collector or cataloger, you can use the `LUNAR_BIN_DIR` environment variable. This will ensure the binary is available in the `PATH`.

### Multi-platform support

For platform-specific logic, you can use `install-<os>-<arch>.sh` or `install-<os>.sh` files. Making your script multi-platform is useful for local development when your local platform may differ from the one Lunar's runners or your CI is running on.

Platform-specific scripts are checked for existence in the following order:

1. `install-<os>-<arch>.sh`
2. `install-<os>.sh`
3. `install.sh`

Only the first script found is executed.

Examples of valid platform-specific script names:

* `install-linux-amd64.sh`
* `install-linux-arm64.sh`
* `install-linux.sh`
* `install-darwin-arm64.sh`
* `install-darwin.sh`

## Development convenience with `earthly/lunar-scripts`

The official `earthly/lunar-scripts` image automatically executes any `install.sh` script found in the plugin directory as part of its entrypoint. This is a convenience feature for quick development iteration, but baking dependencies into your image provides faster startup times in production.

To override this behavior, you can change the `ENTRYPOINT` in your Dockerfile. For example:

```dockerfile
ENTRYPOINT ["/bin/bash"]
```


# Cataloger

Write Lunar catalogers in Bash — environment variables and the lunar catalog CLI commands for saving catalog data.

The Cataloger Bash SDK is a set of Lunar CLI subcommands that allow you to save catalog-related information from within a cataloger.

## Cataloger environment

Earthly Lunar executes catalogers in an environment set up with the following variables:

* `LUNAR_HUB_HOST`: The host of the Lunar Hub.
* `LUNAR_HUB_INSECURE`: Whether to skip SSL verification of the Lunar Hub.
* `LUNAR_BIN_DIR`: The directory where the Lunar CLI is installed.
* `LUNAR_CATALOGER_NAME`: The name of the cataloger. 🚧 Coming Soon
* `LUNAR_CATALOGER_OWNER`: The owner of the cataloger. 🚧 Coming Soon
* `LUNAR_SECRET_<name>`: Any secret set in the Lunar Hub for the cataloger, via `HUB_CATALOGER_SECRETS=NAME:VALUE,NAME2:VALUE2`.
* `LUNAR_COMPONENT_ID`: The ID of the component that the cataloger is running for in `github.com/.../...` format. Only set for `component-repo` and `component-cron` hooks.

## `lunar catalog` CLI command

* Forms:
  * Raw form:

    ```bash
    lunar catalog raw [--json] <json-path> <value>
    ```
  * Component form:

    <div data-gb-custom-block data-tag="hint" data-style="info" class="hint hint-info"><p><strong>Coming Soon</strong> — This feature is not yet available.</p></div>

    ```bash
    lunar catalog component [--name <n>] [--owner <owner>] [--branch <branch>] [--domain <domain>] [--tag <tag>] [--ci-pipeline <ci-pipeline>] [--meta <key>=<value>] [--meta-json <key>=<value>] [<json-value>]
    ```
  * Domain form:

    <div data-gb-custom-block data-tag="hint" data-style="info" class="hint hint-info"><p><strong>Coming Soon</strong> — This feature is not yet available.</p></div>

    \`\`\`bash lunar catalog domain \[--name ] \[--description ] \[--owner ] \[--meta =] \[--meta-json =] \[] \`\`\`

The `lunar catalog` command is used to save catalog-related information from within a cataloger. The command takes a JSON path and a value as arguments. The JSON path is used to specify the location in the JSON object where the value should be stored.

### `--json`

The `--json` flag is used to specify that the value should be interpreted as a JSON object, JSON array, or JSON scalar (string, number, etc). If the flag is not provided, the value is interpreted as a raw string.

### `<json-path>`

* Type: `string`

The JSON path is used to specify the location in the JSON object where the value should be stored. The path is specified using dot notation, where each level of the object is separated by a dot. For example, `.foo.bar.baz` would specify the `baz` property of the `bar` object, which is a property of the `foo` object.

### `<value>`

By default, the value is interpreted as a raw string. If the `--json` flag is provided, the value is interpreted as a JSON object, JSON array, or JSON scalar (string, number, etc).

If the value is `-`, the value is read from standard input. This is useful for piping the output of a command into the `lunar catalog` command.

### `--name <name>`

* Type: `string`
* Optional

The `--name` flag is used to specify the name of the component or domain.

When using the `component-repo` or `component-cron` cataloger hooks, the name of the component and the domain will be inferred automatically from the cataloger context in which the command is executed (`LUNAR_COMPONENT_ID` is set for both hooks).

In Component and Domain forms, the name must be provided either via this flag, via the cataloger context (`component-repo` and `component-cron` hooks), or via the JSON value.

### `--owner <owner>`

* Type: `string`
* Optional

The `--owner` flag is used to specify the owner of the component or domain.

### `--branch <branch>`

* Type: `string`
* Optional

The `--branch` flag is used to specify the branch of the component.

### `--domain <domain>`

* Type: `string`
* Optional

The `--domain` flag is used to specify the domain of the component. The domain must be specified in the format `foo.bar`, where `foo` is the parent domain and `bar` is the child domain.

### `--tag <tag>`

* Type: `string`
* Optional

The `--tag` flag is used to specify the tag of the component. This flag may be specified multiple times to add multiple tags to the component.

### `--ci-pipeline <ci-pipeline>`

* Type: `string`
* Optional

The `--ci-pipeline` flag is used to specify the CI pipeline of the component. This flag may be specified multiple times to add multiple CI pipelines to the component.

### `--meta <key>=<value>`

* Type: `string`
* Optional

The `--meta` flag is used to specify a metadata key-value pair for the component or domain. This flag may be specified multiple times to add multiple metadata entries. The value is interpreted as a raw string.

### `--meta-json <key>=<value>`

* Type: `string`
* Optional

The `--meta-json` flag is used to specify a metadata key-value pair for the component or domain where the value is interpreted as JSON. This flag may be specified multiple times to add multiple metadata entries. The value must be valid JSON (object, array, or scalar).

### `--description <description>`

* Type: `string`
* Optional

The `--description` flag is used to specify the description of the domain.

### `<json-value>`

* Type: `JSON string`
* Optional

The JSON value is used to specify the value to be stored in the JSON object.


# Collector

The lunar collect CLI for collecting SDLC metadata into a component's JSON, and the environment available to Lunar collectors.

The Collector Bash SDK is a set of Lunar CLI subcommands that allow you to collect SDLC metadata from within a collector, or from external systems, such as your CI system directly.

## Collector environment

Earthly Lunar executes collectors in an environment set up with the following variables:

* `LUNAR_HUB_HOST`: The host of the Lunar Hub.
* `LUNAR_HUB_TOKEN`: The authentication token for the Lunar Hub.
* `LUNAR_HUB_GRPC_PORT`: The gRPC port of the Lunar Hub.
* `LUNAR_HUB_HTTP_PORT`: The HTTP port of the Lunar Hub.
* `LUNAR_HUB_INSECURE`: Whether to skip SSL verification of the Lunar Hub.
* `LUNAR_BIN_DIR`: The directory where the Lunar CLI is installed.
* `LUNAR_COLLECTOR_NAME`: The name of the collector.
* `LUNAR_COLLECTOR_CI_PIPELINE`: The CI pipeline of the component that the collector is running for (if this is a CI collector). 🚧 Coming Soon
* `LUNAR_COMPONENT_ID`: The ID of the component that the collector is running for in `github.com/.../...` format.
* `LUNAR_COMPONENT_DOMAIN`: The domain of the component.
* `LUNAR_COMPONENT_OWNER`: The owner of the component.
* `LUNAR_COMPONENT_HEAD_BRANCH`: The head branch of the PR (branch that contains the changes), if applicable.
* `LUNAR_COMPONENT_BASE_BRANCH`: The base branch of the PR (branch to merge the changes into), if applicable.
* `LUNAR_COMPONENT_PR`: The PR number of the component, if applicable.
* `LUNAR_COMPONENT_TAGS`: The tags of the component as a JSON array.
* `LUNAR_COMPONENT_GIT_SHA`: The Git SHA of the component that the collector is running for.
* `LUNAR_COMPONENT_META`: The component's `.meta` (an arbitrary key→value map, e.g. `pagerduty/service-id`) as a JSON object. Only set when the component has meta.
* `LUNAR_SECRET_<name>`: Any secret configured for collectors — either via `HUB_COLLECTOR_SECRETS=NAME:VALUE,NAME2:VALUE2` or at runtime using `lunar secret set <name> <value>`.

### CI collector environment

For `ci-{before,after}-job`, `ci-{before,after}-step` and `ci-{before,after}-command` hooks the following entries are also available:

* `LUNAR_CI`: The CI provider identifier (github, buildkite, ...).
* `LUNAR_CI_PIPELINE_RUN_ID`: Unique identifier for the current pipeline run. Pipeline are the top-level units of execution in a CI system. Also known as "workflow" in some CIs.
* `LUNAR_CI_PIPELINE_RUN_ATTEMPT`: Pipeline run attempt (1-based).
* `LUNAR_CI_PIPELINE_DEFINITION_REF`: URL to the pipeline source code.
* `LUNAR_CI_PIPELINE_NAME`: Name of the pipeline, if available.
* `LUNAR_CI_JOB_ID`: Job id. Jobs are units of parallel execution within a pipeline. Also known as "stages" in some CIs.
* `LUNAR_CI_JOB_NAME`: Job name, if available.

For `ci-{before,after}-step` and `ci-{before,after}-command` hooks the following entries are also available:

* `LUNAR_CI_STEP_INDEX`: Step index (1-based). Steps are the sequential units of execution within a job.
* `LUNAR_CI_STEP_NAME`: Step name, if available.
* `LUNAR_CI_STEP_ID`: Step ID, if available.
* `LUNAR_CI_STEP_USES`: Reference to the step definition, for reusable step definitions.
* `LUNAR_CI_STEP_RUN`: Step source code, for inline definitions.

For `ci-{before,after}-command` hooks the following entries are also available:

* `LUNAR_CI_COMMAND`: Command and arguments of the hooked command, as a JSON string.
* `LUNAR_CI_COMMAND_BIN`: Binary name only (no directory).
* `LUNAR_CI_COMMAND_BIN_DIR`: Directory containing the binary.
* `LUNAR_CI_COMMAND_ARGS`: Command arguments (excluding the binary) as a JSON array.
* `LUNAR_CI_COMMAND_PID`: Process ID of the hooked command.
* `LUNAR_CI_COMMAND_PPID`: Parent process PID of the hooked command.

## `lunar collect` CLI command

* Form:

  ```bash
  lunar collect [--component <name>] [--sha <commit>] [--pr <number>] \
                [--json] [--array-append] [--pretty] \
                <json-path> <value> [<json-path> <value> ...]
  ```

`lunar collect` collects SDLC metadata into a component's JSON, taking one or more `<json-path> <value>` pairs. Inside a collector the target component and commit default to the current run, so you collect by passing the paths and values to record — use `--json` for non-string values like numbers:

```bash
lunar collect --json .testing.coverage.percentage 87.4
lunar collect .testing.coverage.source.tool codecov
```

### Out-of-band collection

Collection can also happen outside of Lunar collectors — for example, in a CD pipeline, to associate release information with a previously built component's JSON. Pass `--component` and `--sha` to collect against a specific component and commit:

```bash
lunar collect --component github.com/my-org/my-repo --sha "$COMMIT_SHA" \
  .release.tag v1.2.3 \
  .release.image registry.example.com/my-app:v1.2.3
```

The Hub records the collection and re-evaluates policies for that SHA; verify the merged result with `lunar component get-json <component> --git-sha <sha>`.

### `--component <name>`

* Type: `string`
* Optional

The component the metadata is associated with, in `github.com/org/repo` form. Defaults to `$LUNAR_COMPONENT_ID` (set automatically inside a collector); pass it to collect against a specific component.

### `--sha <commit>`

* Type: `string`
* Optional

The full 40-character commit SHA to key the collection on. Defaults to `$GITHUB_SHA`, which GitHub Actions sets automatically — in any other CI, including GitLab, pass it explicitly (for example `--sha "$CI_COMMIT_SHA"`).

### `--pr <number>`

* Type: `int`
* Optional

Optional PR number, to collect against a PR rather than a branch commit.

### `--json`

The `--json` flag is used to specify that the value should be interpreted as a JSON object, JSON array, or JSON scalar (string, number, etc). If the flag is not provided, the value is interpreted as a raw string.

Example:

```bash
lunar collect --json '.foo.bar1' 'true'              # interprets as a JSON scalar (boolean)
lunar collect '.foo.bar2' 'true'                     # interprets as a raw string
lunar collect --json '.foo.bar3' '{"baz": "value 3"}' # interprets as a JSON object
lunar collect --json '.foo.bar4' '["value 4"]'        # interprets as a JSON array
```

Will result in:

```json
{
  "foo": {
    "bar1": true,
    "bar2": "true",
    "bar3": {"baz": "value 3"},
    "bar4": ["value 4"]
  }
}
```

Note: Without `--json`, values are always treated as raw strings regardless of their content. For example, `lunar collect '.foo.bar' '{"key": "val"}'` would store the literal string `"{\"key\": \"val\"}"`, not a JSON object.

### `--array-append`

The `--array-append` flag is used to specify that the value should be appended to an array at the specified JSON path. If the path does not exist, a new array will be created. If the path exists and is not an array, the value will be replaced with a new array containing the existing value and the new value.

Since arrays are concatenated during the component JSON merge, using `--array-append` is equivalent to wrapping the value in a JSON array. The following two commands are equivalent:

```bash
lunar collect --array-append '.foo.bar' 'a value'
lunar collect --json '.foo.bar' '["a value"]'
```

Example:

```bash
lunar collect --json --array-append '.foo.bar' '{"baz": "value 1"}'
lunar collect --json --array-append '.foo.bar' '{"baz": "value 2"}'
```

Will result in:

```json
{
  "foo": {
    "bar": [
      {"baz": "value 1"},
      {"baz": "value 2"}
    ]
  }
}
```

### `--pretty` | `-p`

Pretty-print the JSON output.

### `<json-path>`

* Type: `string`

The JSON path is used to specify the location in the JSON object where the value should be stored. The path is specified using dot notation, where each level of the object is separated by a dot. For example, `.foo.bar.baz` would specify the `baz` property of the `bar` object, which is a property of the `foo` object.

### `<value>`

By default, the value is interpreted as a raw string. If the `--json` flag is provided, the value is interpreted as a JSON object, JSON array, or JSON scalar (string, number, etc).

If the value is `-`, the value is read from standard input. This is useful for piping the output of a command into the `lunar collect` command.


# Python SDK

Overview of Lunar's Python SDK for writing custom Lunar policies (with collector support coming soon).

The Python SDK provides interfaces for extending Lunar's functionality through Python-based plugins. This SDK enables you to create custom collectors and policies tailored to your organization's needs.

## SDK Components

Explore these pages to learn about the types of plugins you can create with the Python SDK:

* [Collector](/plugin-sdks/python-sdk/collector) - Create collectors for gathering component metadata (**coming soon**)
* [Policy](/plugin-sdks/python-sdk/policy) - Develop policies for validating components against custom rules


# Installing dependencies

How to install Python dependencies for Lunar policies, using a custom Docker image or native runtime execution.

If your policy requires dependencies, you have two options depending on your execution environment:

1. **Custom Docker image** - Install dependencies at build time in your Dockerfile
2. **Native execution** - Dependencies are installed automatically at runtime

Your `requirements.txt` should contain the `lunar_policy` package at a minimum:

```txt
lunar_policy==0.1.6
```

## Custom Docker Image

When running in containers, create a custom Docker image with all dependencies pre-installed. This provides faster startup times, reproducible builds, and eliminates network dependencies at runtime.

Create a Dockerfile that inherits from the official `earthly/lunar-scripts` image and installs your dependencies at build time:

{% code title="Dockerfile" %}

```dockerfile
FROM earthly/lunar-scripts:1.0.0

# Copy and install Python dependencies
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
```

{% endcode %}

Then configure your plugin or `lunar-config.yml` to use this image:

```yaml
image: my-org/my-custom-image:v1.0
```

For more details on image configuration, see [Images](/configuration/lunar-config/images).

## Native Execution

When running with `image: native` (no container), place your `requirements.txt` in the same directory as your `lunar-config.yml` or `lunar-policy.yml` file. Dependencies are installed automatically before the policy is run, and only once in each environment.

## Development convenience with `earthly/lunar-scripts`

The official `earthly/lunar-scripts` image automatically installs dependencies from any `requirements.txt` file found in the plugin directory as part of its entrypoint. This is a convenience feature for quick development iteration, but baking dependencies into your image provides faster startup times in production.

To override this behavior, you can change the `ENTRYPOINT` in your Dockerfile. For example:

```dockerfile
ENTRYPOINT ["/usr/bin/python3"]
```


# Collector

Placeholder for the upcoming Python Collector SDK used to gather component metadata in Lunar.

Coming soon! This page will contain documentation for the upcoming Python Collector SDK.

In the meantime, see the [Collector Bash SDK](/plugin-sdks/bash-sdk/collector) for similar functionality in Bash.


# Policy

Write Lunar policies in Python with the lunar\_policy package — load component metadata, make assertions, and handle pending data.

The `lunar_policy` Python package provides utilities for working with Lunar policies, allowing you to load, query, and make assertions about component metadata, such as the [component JSON](/docs/component-json).

For the reference documentation check out:

* [Check](/plugin-sdks/python-sdk/policy/check) - Main class for making assertions about component data
* [Node](/plugin-sdks/python-sdk/policy/node) - Navigate and explore JSON data
* [CheckStatus](/plugin-sdks/python-sdk/policy/check-status)
* [NoDataError](/plugin-sdks/python-sdk/policy/no-data-error)
* [SkippedError](/plugin-sdks/python-sdk/policy/skipped-error)

## Installation

When using the official `earthly/lunar-scripts` Docker image (recommended), the `lunar-policy` package is already pre-installed. See [Images](/configuration/lunar-config/images) for details on configuring default images.

For native execution or custom images, the package is available through pip:

```bash
pip install lunar-policy
```

## Policy environment

Earthly Lunar executes policies in an environment set up with the following variables:

* `LUNAR_HUB_HOST`: The host of the Lunar Hub.
* `LUNAR_HUB_INSECURE`: Whether to skip SSL verification of the Lunar Hub.
* `LUNAR_POLICY_NAME`: The name of the policy being executed. 🚧 Coming Soon
* `LUNAR_INITIATIVE_NAME`: The name of the initiative the policy belongs to. 🚧 Coming Soon
* `LUNAR_POLICY_OWNER`: The owner of the policy. 🚧 Coming Soon
* `LUNAR_COMPONENT_ID`: The ID of the component being checked in `github.com/.../...` format.
* `LUNAR_COMPONENT_DOMAIN`: The domain of the component.
* `LUNAR_COMPONENT_OWNER`: The owner of the component.
* `LUNAR_COMPONENT_PR`: The PR number of the component, if applicable.
* `LUNAR_COMPONENT_GIT_SHA`: The Git SHA of the component that the policy is being executed for.
* `LUNAR_COMPONENT_TAGS`: The tags of the component.
* `LUNAR_COMPONENT_META`: The component's `.meta` (an arbitrary key→value map, e.g. `pagerduty/service-id`) as a JSON object. Only set when the component has meta.
* `LUNAR_BUNDLE_PATH`: Used internally by Lunar to pass in the component JSON and the deltas to the policy.
* `LUNAR_SECRET_<name>`: Any secret set in the Lunar Hub for the policy, via `HUB_POLICY_SECRETS=NAME:VALUE,NAME2:VALUE2`.

{% hint style="warning" %}
Policies are re-evaluated frequently as each piece of data becomes available, so design your policy execution to be fast (no external API calls, no heavy processing, etc.). If you need to perform any expensive operations, consider using a collector instead, and passing the necessary data via the component JSON.
{% endhint %}

{% hint style="warning" %}
Policies are re-evaluated between component data collections. This means that not all data is available from the beginning, and your policy should correctly report a `pending` status when it cannot make a decision yet. Much of this is handled automatically for you — see [Handling Missing Data](#handling-missing-data) below for more details.
{% endhint %}

## Core Components

The Policy SDK provides several key classes to help you write policies:

### Check

The `Check` class provides a fluent interface for making assertions about policy data. It tracks accessed data within the component JSON for traceability purposes.

```python
from lunar_policy import Check

# Create a check with a name and optional description
with Check("my-check", "Validates important properties") as check:
    # Get data using JSONPath
    value = check.get_value(".path.to.data")
    
    # Make assertions
    check.assert_equals(check.get_value(".api.endpoints[0].method"), "GET")
    check.assert_greater_or_equal(value, 50)
    check.assert_contains(check.get_value(".api.endpoints[0].path"), "/")
```

👉 For detailed reference, see the [Check reference documentation](/plugin-sdks/python-sdk/policy/check).

### Node

The `Node` class is used to navigate and explore JSON data.

```python
from lunar_policy import Check, Node

with Check("my-check", "Validates important properties") as check:
    # Create a node from a check
    node = check.get_node(".path.to.data")
    if node.exists():
        # Get the value of the node
        value = node.get_value()
        # Make assertions on the node
        node.assert_equals(value, "expected_value")
```

👉 For detailed reference, see the [Node reference documentation](/plugin-sdks/python-sdk/policy/node).

### NoDataError

The `NoDataError` exception is used to indicate that required data for a policy check is still pending (hasn't been collected yet).

👉 For detailed reference, see the [NoDataError reference documentation](/plugin-sdks/python-sdk/policy/no-data-error).

### SkippedError

The `SkippedError` exception is used to indicate that a check should be skipped because it is not applicable to the current component (e.g., Go-specific checks on a Java repository).

👉 For detailed reference, see the [SkippedError reference documentation](/plugin-sdks/python-sdk/policy/skipped-error).

## Automatic Data Loading

When a policy is executed through Lunar Hub, Lunar passes in the relevant context via `LUNAR_BUNDLE_PATH`. This context includes the [component JSON](/docs/component-json) for the component that is being checked. When you create a `Check` instance, the library automatically loads this data under the hood when `node` is not provided.

## Executing Policies Locally

To execute a policy locally for testing purposes, you can use the `lunar` CLI.

```bash
lunar policy dev --component-json path/to/component.json ./path/to/policy.py
```

If you would like to use the real component JSON of one of your components, you can do so via the command:

```bash
lunar policy dev --component github.com/my-org/my-repo ./path/to/policy.py
```

## Check outcomes

Checks can result in the following possible outcomes:

* `pass`: The check passed successfully. All assertions within the check were satisfied.
* `fail`: The check failed. One or more assertions within the check were not satisfied. This generally means that the developer working on the component needs to fix the issue.
* `pending`: The check could not be completed due to data not being available yet. This is due collection not having finished yet (code or cron collectors are still running, or the CI is still running).
* `error`: The check encountered an error during execution. This indicates an unexpected error occurred during the check execution, such as a runtime exception. This is generally a bug either in the collection logic or in the policy code.
* `skipped`: The check was intentionally skipped because it is not applicable to the current component. Skipped checks are not shown to end-user developers but are available in the SQL API for analysis purposes.

## Handling Missing Data

Lunar makes a clear distinction between temporarily missing component JSON data (data is pending), permanently missing component JSON data, and failing component JSON data. This is needed under the hood to be able to provide partial policy results while collectors are still running. Policies that have enough data will provide accurate results, while policies that don't have enough data will often report a `pending` status.

Here is a breakdown of how the different statuses are determined:

* Temporarily missing data (`pending` status): This is when some or all of the component JSON data required for assertions is not present, but the collector are still running. In this case, the check will report a `pending` status for now, thanks to methods like `get_value`, `exists` and `assert_exists` automatically raising `NoDataError` when not enough data is available and collectors are still running.
* Permanently missing data - sometimes expected on failures (`fail` status): This is when some of the component JSON data required for assertions is not present, and the collectors have finished running. `assert_exists` will report a failure in this case.
* Permanently missing data - unexpected (`error` status): This is when some of the component JSON data required for assertions is not present, and the collectors have finished running. `get_value` will report a `ValueError` after collectors finished, resulting in an `error` status.
* Failing data (`fail` status): This is when the data is present, but the assertions (e.g. `assert_equals`, `assert_true`, etc.) fail.

This means that you should use `get_value` to access data when you are assuming that the data will eventually be provided by a collector. Or use `assert_exists` (or `exists` within an if condition) to test for the existence of data if that's what the pass/fail outcome of the check should depend on.

### Best Practices for Handling Missing Data

Below are examples demonstrating different approaches to handling missing data in policies:

{% columns %}
{% column %}

#### Bad: Blindly Assuming Data Exists

This approach incorrectly assumes all fields exist after retrieving an object, which can lead to errors when data is temporarily missing:

```python
with Check("api-security-check") as check:
    # Bad: Gets the entire API object once and assumes all fields exist
    api = check.get_value(".api")
    
    # These will cause policy errors if api is None or missing expected fields
    check.assert_true(api["requires_auth"], "API should require authentication")
    rate_limit = api["rate_limit"]
    check.assert_equals(rate_limit, 100, f"API rate limit should be 100, but found {rate_limit}")
    check.assert_contains(api["security_headers"], "Content-Security-Policy")
```

{% endcolumn %}

{% column %}

#### Good: Targeted Access via the Check API

This approach uses targeted field access and relies on the Check API to handle missing data automatically:

```python
with Check("api-security-check") as check:
    # Direct field access - raises NoDataError if path doesn't exist yet
    check.assert_true(check.get_value(".api.requires_auth"), "API should require authentication")
    
    # Test for data existence when that's the actual requirement
    check.assert_exists(".api.requires_auth", "API is missing requires_auth field")
    
    # Conditional checks using exists() - only run assertions if data exists
    if check.exists(".api"):
        check.assert_true(check.get_value(".api.requires_auth"), "API should require authentication")
    
    # Working with nodes + exists() to navigate and explore data
    api_node = check.get_node(".api")
    if api_node.exists():
        rate_limit = api_node.get_value(".rate_limit") 
        check.assert_equals(rate_limit, 100, f"API rate limit should be 100, but found {rate_limit}")
        check.assert_contains(api_node.get_value(".security_headers"), "Content-Security-Policy")
```

{% endcolumn %}
{% endcolumns %}

## Writing Unit Tests for Policies

Let's assume that we have the following policy:

```python
from lunar_policy import Check, Node

def verify_readme(node=None):
    check = Check("readme-long-enough", node=node)
    with check:
        lines = check.get_value('.readme.lines')
        check.assert_greater_or_equal(
            lines, 50,
            f'README.md should have at least 50 lines. Current count: {lines}'
        )
    return check

if __name__ == "__main__":
    verify_readme()
```

Here's an example showing how to write unit tests:

```python
import unittest
from lunar_policy import Check, Node, CheckStatus

class TestReadmePolicy(unittest.TestCase):
    def test_not_long_enough(self):
        component_json = {
            "readme": {
                "lines": 49,
                "missing": False
            }
        }
        node = Node.from_component_json(component_json)
        check = verify_readme(node)
        
        # Verify the check failed because the README isn't long enough
        self.assertEqual(check.status, CheckStatus.FAIL)
        self.assertEqual(check.failure_reasons[0], "README.md should have at least 50 lines. Current count: 49")

if __name__ == "__main__":
    unittest.main()
```

You can run the test with:

```bash
python -m unittest test_readme_policy.py
```


# Check

Reference for the Check class in Lunar's Python Policy SDK — the fluent API for asserting on component metadata and tracking results.

The `Check` class provides a fluent interface for making assertions about policy data. The object keeps track of accessed data within the component JSON and records that for traceability purposes. The final result of a check will have not just the status (`pass`, `fail`, `pending`, `error`, or `skipped`), but complete information about which JSON paths were used to reach this conclusion. Designed to be used as a context manager with Python's `with` statement, the `Check` class automatically handles `NoDataError` (turns it into `pending` status), `SkippedError` (turns it into `skipped` status), and tracks result statuses.

## Constructor

```python
Check(name, description=None, node=None)
```

Creates a new check instance that can be used to make assertions about the component data. If the component data is not provided, it will be loaded automatically from the environment via `LUNAR_BUNDLE_PATH`.

* **name** (str): A unique identifier for this check
* **description** (str, optional): A human-readable description of what this check validates
* **node** (Node, optional): An alternate Node instance to use for this check, instead of loading it from the environment. Useful for unit testing.

## Context Manager

The `Check` class is designed to be used as a context manager with Python's `with` statement. This is the recommended way to use the class as it ensures proper setup, teardown, and error handling.

```python
with Check("check-name", "Check description") as check:
    # Make assertions using check methods
    check.assert_true(check.get_value(".path.to.data"))
```

When used as a context manager, the `Check` class:

1. **On enter**: Sets up the check context and automatically loads component data if not provided
2. **On exit**: Records the check result with its status and all accessed data paths
3. **Exception handling**:
   * Catches and suppresses `NoDataError`, and sets the check status to `pending`, if collectors are still running.
   * Otherwise, propagates `NoDataError` and records as `error` status since `NoDataError` is unexpected after collectors finished.
   * Catches and suppresses `SkippedError`, sets the check status to `skipped`, and drops any previously recorded assertions.
   * Propagates other exceptions and records them as `error` status with the exception message

## JSONPath Compatibility

{% hint style="info" %}
We do not support the entirety of JSONPath as defined in RFC 9535. However, we do support a strict subset of the language:

* `$` is implicit at the beginning of paths.
* You can access members of an object by key using either `.dot.syntax` or `['literal']['syntax']`.
* You can index into an array by number only. No wildcards or filters allowed.
  {% endhint %}

## Data Access Methods

### get\_value

```python
get_value(path=".")
```

Retrieves data from the component JSON using a JSONPath expression. This method raises `ValueError` if the path is invalid.

Missing data behavior:

* Raises `NoDataError` **before** collectors finished (results in `pending` status).
* Raises `ValueError` **after** collectors finished (results in `error` status).

This means that `get_value` is best used when you are assuming that the data will eventually be provided by a collector.

* **path** (str): JSONPath expression to query the component data (default: "." for root)
* **Returns**: The value at the specified path, or raises `NoDataError` or `ValueError`.

Example:

```python
# Get the number of lines in the README
lines = check.get_value(".readme.lines")

# Get the entire component data
all_data = check.get_value()
```

### get\_value\_or\_default

```python
get_value_or_default(path=".", default=None)
```

Retrieves data from the component JSON using a JSONPath expression. If the value is missing for any reason, it returns the specified default instead.

* **path** (str): JSON path relative to the root of the component JSON (default: ".")
* **default**: The value to return if there is no value at this path

Example:

```python
# Given this component JSON:
# {
#     "existing": {
#         "value": "hello"
#     }
# }

val = node.get_value_or_default(".existing.value", "goodbye") # val will be "hello"
val = node.get_value_or_default(".missing", "goodbye") # val will be "goodbye"
```

### get\_all\_values

```python
get_all_values(path=".")
```

Retrieves data from the component JSON deltas using a JSONPath expression. Use this when you collect something at the same path multiple times to help assert on all values. This method raises `ValueError` if the path is invalid.

Missing data behavior:

* Raises `NoDataError` **before** collectors finished (results in `pending` status).
* Raises `ValueError` **after** collectors finished (results in `error` status).

This means that `get_all_values` is best used when you are assuming that the data will eventually be provided by a collector.

* **path** (str): JSONPath expression to query the component data (default: "." for root)
* **Returns**: A list of all values found at the specified path, or raises `NoDataError` or `ValueError`.

Example:

```python
# Get the number of lines in the README
lines = check.get_all_values(".readme.lines")
```

### get\_node

```python
get_node(path)
```

Gets a Node at the given path. Uses lazy evaluation - no data access or path tracking until value is needed.

* **path** (str): JSONPath expression to query the component data
* **Returns**: A `Node` instance at the specified path
* **Raises**: `ValueError` if the path syntax is invalid

The returned Node object provides methods like `get_value()`, `get_node()`, `exists()`, and supports iteration. See the [Node class documentation](/plugin-sdks/python-sdk/policy/node) for complete details.

Example:

```python
config_node = check.get_node(".config")
if config_node.exists():
    config = config_node.get_value()
```

### exists

```python
exists(path=".")
```

Returns `True` if the path exists in the component data.

Missing path behavior:

* Raises `NoDataError` **before** collectors finished (results in `pending` status).
* Returns `False` **after** collectors finished.

## Node-like Iteration Methods

The `Check` class supports iteration methods that make it behave like a Node for duck-typing compatibility. These methods allow you to iterate over the root component data.

### Iterating over Check Fields

```python
for item in check:
    # Process item
```

Makes Check iterable like a Node. For dictionaries, yields keys. For arrays, yields Node objects.

* **For dict-like data**: Yields string keys
* **For array-like data**: Yields Node objects for each array element
* **Raises**: `ValueError` if the component data is not a dict or array, `NoDataError` if data is not available yet

Example:

```python
# Iterate over top-level keys in component data
with Check("iterate-check") as check:
    for key in check:
        print(f"Top-level key: {key}")
```

### items

```python
items()
```

Get key-value pairs when the Check points to dict-like component data.

* **Returns**: Iterator of (key, Node) tuples for dict-like data
* **Raises**: `ValueError` if the component data is not a dictionary, `NoDataError` if data is not available yet

Example:

```python
with Check("items-check") as check:
    for key, value_node in check.items():
        value = value_node.get_value()
        print(f"{key}: {value}")
```

## Control Flow Methods

### skip

```python
skip(reason="")
```

Unconditionally skips the check with an optional reason. When called, this method raises a `SkippedError` exception that is caught by the Check's context manager. The check is effectively canceled - any assertions (passed or failed) that may have been recorded before `skip` is called are dropped/ignored, and the final status of the check is marked as `skipped`.

Skipped checks are not shown to end-user developers but are available in the SQL API for analysis purposes.

* **reason** (str, optional): An optional message explaining why the check was skipped

Example:

```python
if not check.exists(".go"):
    check.skip("No Go files exist in repository")

if not check.exists(".kubernetes.manifests"):
    check.skip("No Kubernetes manifests exist")
```

### fail

```python
fail(message)
```

Unconditionally fails the check with a given message.

* **message** (str): The message to display when the check fails

Example:

```python
if my_complex_condition():
    check.fail("This is a policy failure")
```

## Assertion Methods

All assertion methods have these common parameters:

* **value**: The value to be asserted
* **failure\_message** (str, optional): Custom message to display if the assertion fails

Additionally, all assertion methods raise `NoDataError` if the path doesn't exist in the component data.

### assert\_true

```python
assert_true(value, failure_message=None)
```

Asserts that a value is `True`.

Example:

```python
# Assert that authentication is required
check.assert_true(check.get_value(".api.auth_required"), "API should require authentication")
```

### assert\_false

```python
assert_false(value, failure_message=None)
```

Asserts that a value is `False`.

Example:

```python
# Assert that the README.md file is not missing
check.assert_false(check.get_value(".readme.missing"), "README.md file should exist")
```

### assert\_equals

```python
assert_equals(value, expected, failure_message=None)
```

Asserts that a value equals the expected value.

* **expected**: The expected value to compare against

Example:

```python
# Assert that the API endpoint uses GET method
check.assert_equals(check.get_value(".api.endpoints[0].method"), "GET", "Endpoint should use GET method")
```

### assert\_exists

```python
assert_exists(path, failure_message=None)
```

Asserts that a path exists in the component data. If the path was not found, this method raises `NoDataError` before collectors finished, and fails the check after collectors finished.

Missing path behavior:

* Raises `NoDataError` **before** collectors finished (results in `pending` status).
* Fails the check **after** collectors finished (results in `FAIL` status).

Example:

```python
check.assert_exists(".api", "API data not found")
```

### assert\_contains

```python
assert_contains(value, expected, failure_message=None)
```

Asserts that a value contains the expected value (works for strings, lists, etc.).

* **expected**: The value that should be contained

Example:

```python
# Assert that the endpoint path contains a specific substring
check.assert_contains(check.get_value(".api.endpoints[0].path"), "/users")

# Assert that the tags list contains a specific tag
check.assert_contains(check.get_value(".tags"), "api")
```

### assert\_greater

```python
assert_greater(value, expected, failure_message=None)
```

Asserts that a numeric value is greater than the expected value.

* **expected**: The threshold value to compare against

Example:

```python
# Assert that the code coverage is greater than 80%
check.assert_greater(check.get_value(".coverage.percentage"), 80, "Code coverage should be greater than 80%")
```

### assert\_greater\_or\_equal

```python
assert_greater_or_equal(value, expected, failure_message=None)
```

Asserts that a numeric value is greater than or equal to the expected value.

* **expected**: The threshold value to compare against

Example:

```python
# Assert that README has at least 50 lines
check.assert_greater_or_equal(check.get_value(".readme.lines"), 50, "README should have at least 50 lines")
```

### assert\_less

```python
assert_less(value, expected, failure_message=None)
```

Asserts that a numeric value is less than the expected value.

* **expected**: The threshold value to compare against

Example:

```python
# Assert that cyclomatic complexity is less than 15
check.assert_less(check.get_value(".complexity.cyclomatic"), 15, "Cyclomatic complexity should be less than 15")
```

### assert\_less\_or\_equal

```python
assert_less_or_equal(value, expected, failure_message=None)
```

Asserts that a numeric value is less than or equal to the expected value.

* **expected**: The threshold value to compare against

Example:

```python
# Assert that build time is at most 5 minutes
check.assert_less_or_equal(check.get_value(".build.duration_minutes"), 5, "Build should take at most 5 minutes")
```

### assert\_match

```python
assert_match(value, pattern, failure_message=None)
```

Asserts that a string value matches a regular expression pattern.

* **pattern** (str): A regular expression pattern to match against

Example:

```python
# Assert that version follows semantic versioning
check.assert_match(check.get_value(".version"), r"^\d+\.\d+\.\d+$", "Version should follow semantic versioning")
```

## Instance Properties

After a check has been executed (typically after exiting the `with` context), the following properties are available:

### status

```python
status: CheckStatus
```

The final status of the check after execution.

* **Type**: `CheckStatus`
* **Values**: `PASS`, `FAIL`, `PENDING`, `ERROR`, or `SKIPPED`

### failure\_reasons

```python
failure_reasons: List[str]
```

The reasons for failure when the check status is `FAIL`. This property contains an array of detailed error messages from any failed assertions within the check.

* **Type**: `List[str]`
* **Available when**: `status` is `CheckStatus.FAIL`

### name

```python
name: str
```

The name of the check as specified in the constructor.

* **Type**: `str`


# CheckStatus

Reference for the CheckStatus enum used to mark Lunar policy outcomes as PASS, FAIL, PENDING, ERROR, or SKIPPED.

The `CheckStatus` class defines the possible statuses for a check result. It is used to represent the final outcome of a policy check execution.

## Usage

```python
from lunar_policy import CheckStatus
```

## Available Status Values

### PASS

```python
CheckStatus.PASS
```

The check resulted in `pass` status. It passed successfully, so all assertions within the check were satisfied.

### FAIL

```python
CheckStatus.FAIL
```

The check resulted in `fail` status. It failed, so one or more assertions within the check were not satisfied.

### PENDING

```python
CheckStatus.PENDING
```

The check resulted in `pending` status. It could not be completed due to data not being available. This occurs when required data is not yet available in the component JSON, typically because a code or cron collector has not finished running yet, or because the CI is still running (and a CI collector hasn't triggered yet).

### ERROR

```python
CheckStatus.ERROR
```

The check resulted in `error` status. It encountered an error during execution. This indicates an unexpected error occurred during the check execution, such as a runtime exception.

### SKIPPED

```python
CheckStatus.SKIPPED
```

The check resulted in `skipped` status. The check was intentionally skipped using the `skip()` method, typically because the check is not applicable to the current component (e.g., Go-specific checks on a Java repository). Skipped checks are not shown to end-user developers but are available in the SQL API for analysis purposes.


# Node

Reference for the Node class in Lunar's Python Policy SDK — used to navigate, query, and iterate over component JSON data.

The `Node` class represents a specific location in JSON data and allows navigation relative to that location. It provides a way to traverse and explore component data with lazy evaluation, meaning data is only accessed when explicitly requested through methods like `get_value()`, `exists()`, or iteration.

## Class Methods

### from\_component\_json

```python
@classmethod
from_component_json(cls, data)
```

Creates a `Node` instance from a JSON object. Note that a `Node` instance created from the component JSON only will not contain the deltas. While this can be useful for testing most policies, it will not represent a realistic component's data when testing `Check.get_all_values`.

* **data** (dict): A dictionary containing component metadata
* **Returns**: A new `Node` instance

Example:

```python
component_json = {
    "readme": {
        "lines": 50,
        "missing": False
    }
}
component_data = Node.from_component_json(component_json)
```

### from\_component\_json\_file

```python
@classmethod
from_component_json_file(cls, file_path)
```

Creates a `Node` instance from a JSON file. Note that a `Node` instance created from the component JSON only will not contain the deltas. While this can be useful for testing most policies, it will not represent a realistic component's data when testing `Check.get_all_values`.

* **file\_path** (str): Path to a JSON file containing component metadata
* **Returns**: A new `Node` instance

Example:

```python
component_data = Node.from_component_json_file("path/to/component.json")
```

### from\_bundle\_json

```python
@classmethod
from_bundle_json(cls, data)
```

Creates a `Node` instance from a bundle JSON object.

* **data** (dict): A dictionary containing bundle data
* **Returns**: A new `Node` instance

### from\_bundle\_file

```python
@classmethod
from_bundle_file(cls, file_path)
```

Creates a `Node` instance from a bundle JSON file.

* **file\_path** (str): Path to a JSON file containing bundle data
* **Returns**: A new `Node` instance

## Data Access Methods

### get\_value

```python
get_value(path=".")
```

Gets the raw value at the given path relative to this node.

* **path** (str): JSON path relative to this node (default: "." for this node's value)
* **Returns**: The raw value (dict, list, string, number, boolean, etc.)
* **Raises**: `ValueError` if the path is invalid or doesn't exist, `NoDataError` if data is not available yet

Example:

```python
# Get the current node's value
node = check.get_node(".config.database")
host = node.get_value(".host")  # Relative to .config.database
port = node.get_value(".port")  # Relative to .config.database

# Get the node's own value
database_config = node.get_value()  # Returns the entire database config object as a dict
```

### get\_value\_or\_default

```python
get_value_or_default(path=".", default=None)
```

Gets the raw value at the given path relative to this node. If the value is missing for any reason, it returns the specified default instead.

* **path** (str): JSON path relative to this node (default: "." for this node's value)
* **default**: The value to return if there is no value at this path

Example:

```python
# Given this component JSON:
# {
#     "existing": {
#         "value": "hello"
#     }
# }

val = node.get_value_or_default(".existing.value", "goodbye") # val will be "hello"
val = node.get_value_or_default(".missing", "goodbye") # val will be "goodbye"
```

### get\_node

```python
get_node(path)
```

Gets a Node at the given path relative to this node. Uses lazy evaluation - data is not accessed until value is needed.

* **path** (str): JSON path relative to this node
* **Returns**: A new Node instance at the specified path
* **Raises**: `ValueError` if the path syntax is invalid

### exists

```python
exists(path=".")
```

Checks if a path exists relative to this node.

Missing path behavior:

* Raises `NoDataError` **before** collectors finished (results in `pending` status).
* Returns `False` **after** collectors finished.

Example:

```python
host_node = check.get_node(".config.host")
if host_node.exists():
    host = host_node.get_value()
```

## Iteration Methods

### Iterating over Node

```python
for item in node:
    # Process item
```

Makes Node iterable. For dictionaries, yields keys. For arrays, yields Node objects.

* **For dict-like data**: Yields string keys
* **For array-like data**: Yields Node objects for each array element
* **Raises**: `ValueError` if the node doesn't point to a dict or array, `NoDataError` if data is not available yet

Example:

```python
# Iterate over dictionary keys
config_node = check.get_node(".config")
for key in config_node:
    print(f"Config key: {key}")

# Iterate over array elements
items_node = check.get_node(".items")
for item_node in items_node:
    name = item_node.get_value(".name")
    print(f"Item name: {name}")
```

### items

```python
items()
```

Get key-value pairs when this Node points to a dict-like structure.

* **Returns**: Iterator of (key, Node) tuples for dict-like data
* **Raises**: `ValueError` if the node doesn't point to a dictionary, `NoDataError` if data is not available yet

Example:

```python
config_node = check.get_node(".config")
for key, value_node in config_node.items():
    value = value_node.get_value()
    print(f"{key}: {value}")
```


# NoDataError

Reference for the NoDataError exception that signals required component data is not yet collected, marking a Lunar check as pending.

The `NoDataError` exception is used to indicate that there is not enough data to make a determination on whether the check should pass or fail. This is due to the required data not having been collected yet and thus not being present in the component JSON.

This exception is automatically caught by the `Check` context manager, which sets the check status to `pending` if collectors are still running. Note that the `NoDataError` is unexpected after collectors finished, and will result in an `error` status.

The purpose of `NoDataError` is to distinguish between:

1. **Data is pending**: When required data is yet to be collected (for example, a CI collector hasn't triggered yet, or a code collector hasn't finished running yet)
2. **Failed Assertions**: When data is present but doesn't meet the policy requirements (results in `fail` status)

This distinction is important because Lunar re-evaluates policies as data becomes available. A `pending` status indicates that the policy should be re-evaluated when more data is collected, while a `fail` status indicates a definitive policy violation.

## Constructor

```python
NoDataError(message=None)
```

* **message** (str, optional): A message describing why the data is missing


# SkippedError

Reference for the SkippedError exception used to skip a Lunar check when it does not apply to the current component.

The `SkippedError` exception is used to indicate that a check should be skipped and not evaluated. This is typically used for applicability detection - when a check is not relevant to the current component (e.g., Go-specific checks on a Java repository, or Kubernetes checks when no manifests are present).

This exception is automatically caught by the `Check` context manager, which:

* Sets the check status to `skipped`
* Drops any previously recorded assertions (both passed and failed)
* Suppresses the exception so it doesn't propagate

Skipped checks are not shown to end-user developers but are available in the SQL API for analysis and debugging purposes.

## Constructor

```python
SkippedError(message=None)
```

* **message** (str, optional): A message describing why the check was skipped

## Usage

The `SkippedError` is typically raised automatically by the `Check.skip()` method rather than being raised directly:

```python
# Recommended approach - use Check.skip()
with Check("go-version-check") as check:
    if not check.exists(".go"):
        check.skip("No Go files found in repository")
    
    # This code won't execute if skip() was called
    go_version = check.get_value(".go.version")
    check.assert_match(go_version, r"^1\.(19|20|21)$")
```


# Overview

Overview of Lunar's read-only SQL API for querying components, domains, policies, checks, and other Hub data via PostgreSQL clients.

The SQL API provides programmatic access to Lunar's data through SQL queries. Through this interface, you can access and analyze information about your components, domains, policies, and more.

## Accessing the SQL API

To access the SQL API, use the Lunar CLI command `lunar sql connection-string` to get a PostgreSQL connection string that can be used with any PostgreSQL client:

```bash
# Get the connection string
lunar sql connection-string

# Connect using psql (interactive)
psql $(lunar sql connection-string)

# Execute a query directly
psql $(lunar sql connection-string) -c "SELECT * FROM components LIMIT 5"
```

The access is **read-only** and restricted to only the views described in this documentation.

## Available Views

For detailed information about available views, explore these pages:

* [domains](/sql-api/views/domains)
* [components](/sql-api/views/components)
* [component\_deltas](/sql-api/views/component-deltas)
* [initiatives](/sql-api/views/initiatives)
* [policies](/sql-api/views/policies)
* [checks](/sql-api/views/checks)
* [prs](/sql-api/views/prs)
* [catalog](/sql-api/views/catalog)
* [bypasses](/sql-api/views/bypasses)
* [bypassed\_checks](/sql-api/views/bypassed-checks)

## AI Skill

An AI skill is available to help you craft SQL queries against the Lunar data model. The [lunar-sql](https://github.com/earthly/skills/tree/main/skills/lunar-sql) skill provides your AI assistant with knowledge of the view schemas, JSONB query patterns, and common query examples.

See [Installing AI Skills](/install/skills) for setup instructions.


# Views


# domains

Schema reference for the domains SQL view — one row per domain with its description, owner, and metadata for grouping components.

```
domains
```

The `domains` view provides information about all domains defined in Lunar. Domains represent logical groupings of components that share similar characteristics or purposes.

## Schema

| Column        | Type    | Description                                                                            |
| ------------- | ------- | -------------------------------------------------------------------------------------- |
| `name`        | `TEXT`  | The identifier for the domain in hierarchical dotted format (e.g. `payments.checkout`) |
| `description` | `TEXT`  | A description of the domain                                                            |
| `owner`       | `TEXT`  | The owner of the domain                                                                |
| `meta`        | `JSONB` | Arbitrary metadata associated with the domain                                          |

## Notes

* Domains are defined primarily in the Lunar configuration but can also come from catalogers
* Domains can be used to organize and categorize components
* Components can be assigned to specific domains to create logical groupings
* Domains can be hierarchical, with a parent-child relationship represented by a dotted notation. For example, in the domain name `auth.sso.providers`, `auth` is the parent domain of `auth.sso`, and `auth.sso` is the parent domain of `auth.sso.providers`. The full domain name is stored in the `name` column using dotted notation.

## Usage examples

List all domains in the system:

```sql
SELECT *
FROM domains
ORDER BY name;
```

Find domains owned by a specific person or team:

```sql
SELECT *
FROM domains
WHERE owner = 'platform-team@example.com';
```

Get a count of components by domain:

```sql
SELECT 
  d.name AS domain_name,
  COUNT(c.name) AS component_count
FROM domains d
LEFT JOIN components c ON c.domain = d.name
GROUP BY d.name
ORDER BY component_count DESC;
```

Find all subdomains of a specific parent domain:

```sql
SELECT *
FROM domains
WHERE name LIKE 'payments.%'
ORDER BY name;
```

Find the direct subdomains of a specific domain (only one level down):

```sql
SELECT *
FROM domains
WHERE name LIKE 'payments.%'
  AND name NOT LIKE 'payments.%.%'
ORDER BY name;
```


# components

Schema reference for the components and components\_latest SQL views — time series of tracked components with merged collector metadata per commit.

```
components
components_latest
```

The `components` view is a time series representation of the collection of components that Lunar monitors.

A subset of this data is available in the `components_latest` view, which contains only the latest `git_sha` for each `pr`, in each component. To get the latest row for the default branch, you can filter this view by `pr IS NULL`.

## Schema

| Column           | Type        | Description                                                                                                                    |
| ---------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `component_id`   | `TEXT`      | The identifier for the component - e.g. `github.com/foo/bar/buz`                                                               |
| `timestamp`      | `TIMESTAMP` | The "committed at" UTC timestamp of the `git_sha`                                                                              |
| `git_sha`        | `TEXT`      | The Git commit SHA of the component JSON                                                                                       |
| `pr`             | `BIGINT`    | The pull request number if the commit is part of a pull request. Set to `NULL` for the default branch                          |
| `domain`         | `TEXT`      | The domain of the component in dotted path format (e.g. `payments.analytics.backend`)                                          |
| `owner`          | `TEXT`      | The owner of the component                                                                                                     |
| `tags`           | `TEXT[]`    | The tags associated with the component                                                                                         |
| `meta`           | `JSONB`     | Arbitrary metadata associated with the component                                                                               |
| `component_json` | `JSONB`     | The component JSON object resulting from merging component JSON deltas of the different collectors that ran for this component |

## Notes

* To get the component data for a given component version, you need to filter by `component_id`, and `git_sha`. This pair uniquely identifies each row in the table.
* For repeated updates to the same commit SHA, the `component_json` is updated with the latest JSON object. Individual updates (or "deltas") can be accessed through the [`component_deltas` view](/sql-api/views/component-deltas).

## Usage examples

Find the latest data for a particular component on the default branch. This query is guaranteed to return at most one row.

```sql
SELECT *
FROM components_latest
WHERE component_id = 'github.com/foo/bar/buz'
  AND pr IS NULL;
```

Code coverage of a component over time.

```sql
SELECT timestamp,
       component_json->'codecov'->'report'->'result'->'coverage'->>'total' AS coverage
FROM components
WHERE component_id = 'github.com/foo/bar/buz'
  AND pr IS NULL
  AND jsonb_path_exists(component_json, '$.codecov.report.result.coverage.total')
ORDER BY timestamp ASC;
```

Histogram of Go versions used across all components within a domain.

```sql
SELECT component_json->'go'->>'version' AS go_version,
       COUNT(*) AS count
FROM components_latest
WHERE jsonb_path_exists(component_json, '$.go.version')
  AND (domain = 'analytics' OR domain LIKE 'analytics.%')
  AND pr IS NULL
GROUP BY go_version
ORDER BY count DESC;
```


# component\_deltas

Schema reference for the component\_deltas SQL views — per-collector metadata deltas that merge into each component's full JSON over time.

```
component_deltas
component_deltas_latest
```

The `component_deltas` view is a time series representation of the collection of component metadata deltas that Lunar collects.

A subset of this data is available in the `component_deltas_latest` view, which contains only the deltas for the latest `git_sha` in each `pr`, in each component. To get the latest row for the default branch, you can filter this view by `pr IS NULL`.

## Schema

| Column                 | Type        | Description                                                                                           |
| ---------------------- | ----------- | ----------------------------------------------------------------------------------------------------- |
| `component_id`         | `TEXT`      | The identifier for the component - e.g. `github.com/foo/bar/buz`                                      |
| `timestamp`            | `TIMESTAMP` | The "committed at" UTC timestamp of the `git_sha`                                                     |
| `git_sha`              | `TEXT`      | The Git commit SHA of the component JSON                                                              |
| `pr`                   | `BIGINT`    | The pull request number if the commit is part of a pull request. Set to `NULL` for the default branch |
| `collector_name`       | `TEXT`      | The name of the collector that contributed this delta                                                 |
| `collection_timestamp` | `TIMESTAMP` | The UTC timestamp when the collector ran                                                              |
| `delta`                | `JSONB`     | The metadata delta that was contributed by the collector                                              |

## Notes

* To get the set of deltas for a given component version, you need to filter by `component_id`, and `git_sha`. The ordering of these deltas is defined by the `collection_timestamp`.
* Merging these deltas in the order of `collection_timestamp` will give you the final `component_json` object for a given `component_id` and `git_sha` that is found in the [`components` view](/sql-api/views/components).
* The `timestamp` column (not to be confused with `collection_timestamp`) is guaranteed to be the same for a given `git_sha` of a `component_id`. This timestamp will also match entries in other views such as [`components`](/sql-api/views/components) and [`checks`](/sql-api/views/checks).

## Usage example

Retrieve the deltas associated with the latest `git_sha` for a given component on the default branch.

```sql
SELECT *
FROM component_deltas_latest
WHERE component_id = 'github.com/foo/bar/buz'
  AND pr IS NULL
ORDER BY collection_timestamp ASC;
```


# initiatives

Schema reference for the initiatives SQL view — one row per initiative grouping policies around a shared goal or compliance requirement.

```
initiatives
```

The `initiatives` view provides information about initiatives in Lunar. Initiatives are collections of policies organized around a specific goal or purpose.

## Schema

| Column             | Type        | Description                                              |
| ------------------ | ----------- | -------------------------------------------------------- |
| `id`               | `TEXT`      | The identifier for the initiative                        |
| `description`      | `TEXT`      | A description of the initiative                          |
| `owner`            | `TEXT`      | The owner of the initiative                              |
| `manifest_version` | `TEXT`      | The version of the manifest that defines this initiative |
| `created_at`       | `TIMESTAMP` | When the initiative was created                          |

## Notes

* Initiatives are organized collections of policies designed around a specific goal or compliance requirement
* Each initiative can contain multiple policies and may span different components

## Usage examples

List all initiatives in the system:

```sql
SELECT *
FROM initiatives
ORDER BY id;
```

Find initiatives owned by a specific person:

```sql
SELECT *
FROM initiatives
WHERE owner = 'security-team@example.com';
```

Get a list of initiatives and the number of policies within each:

```sql
SELECT 
  i.id AS initiative_id,
  i.description,
  COUNT(p.id) AS policy_count
FROM initiatives i
LEFT JOIN policies p ON i.id = p.initiative_id
GROUP BY i.id, i.description
ORDER BY policy_count DESC;
```


# policies

Schema reference for the policies SQL view — one row per policy with its enforcement level and parent initiative.

```
policies
```

The `policies` view provides information about all policies defined in Lunar. Policies define specific checks or rules that are applied to components.

## Schema

| Column             | Type        | Description                                                                                                              |
| ------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| `id`               | `TEXT`      | The identifier for the policy                                                                                            |
| `description`      | `TEXT`      | A description of the policy                                                                                              |
| `enforcement`      | `TEXT`      | The enforcement level of the policy. Can be one of `draft`, `score`, `block-pr`, `block-release`, `block-pr-and-release` |
| `initiative_id`    | `TEXT`      | The identifier of the initiative this policy belongs to                                                                  |
| `manifest_version` | `TEXT`      | The version of the manifest that defines this policy                                                                     |
| `created_at`       | `TIMESTAMP` | When the policy was created                                                                                              |
| `meta`             | `JSONB`     | The policy's [`meta`](/configuration/lunar-config/policies#meta) annotations from `lunar-config.yml`, or `{}`            |

## Notes

* Policies define specific checks that are applied to components
* Policies can have different enforcement levels that determine how violations are handled
* Policies are organized into initiatives

## Usage examples

List all policies with their enforcement levels:

```sql
SELECT id, description, enforcement
FROM policies
ORDER BY enforcement;
```

Find all blocking policies within a specific initiative:

```sql
SELECT *
FROM policies
WHERE initiative_id = 'security-compliance'
  AND enforcement IN ('block-pr', 'block-release', 'block-pr-and-release');
```

Find every check enforcing one of your own control identifiers, using the annotations a policy carries in `meta`:

```sql
SELECT cl.*
FROM checks_latest AS cl
JOIN policies AS p ON cl.policy_id = p.id
WHERE p.meta->>'enforce_control' = 'CONTROL123';
```

Get a count of policies by enforcement level:

```sql
SELECT 
  enforcement,
  COUNT(*) as policy_count
FROM policies
GROUP BY enforcement
ORDER BY policy_count DESC;
```


# checks

Schema reference for the checks and checks\_latest SQL views — every policy check evaluation with status, enforcement, component, and PR context.

```
checks
checks_latest
```

The `checks` view is a time series representation of the collection of checks that Lunar ran on components.

A subset of this data is available in the `checks_latest` view, which contains only the checks for the latest `git_sha` in each `pr`, in each component. To get the latest row for the default branch, you can filter this view by `pr IS NULL`.

## Schema

| Column             | Type        | Description                                                                                                             |
| ------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------- |
| `component_id`     | `TEXT`      | The identifier for the component - e.g. `github.com/foo/bar/buz`                                                        |
| `committed_at`     | `TIMESTAMP` | The "committed at" UTC timestamp of the `git_sha`                                                                       |
| `git_sha`          | `TEXT`      | The Git commit SHA of the component JSON                                                                                |
| `pr`               | `BIGINT`    | The pull request number if the commit is part of a pull request. Set to `NULL` for the default branch                   |
| `name`             | `TEXT`      | The name of the check that was run                                                                                      |
| `description`      | `TEXT`      | The description of the check that was run                                                                               |
| `initiative_id`    | `TEXT`      | The identifier of the initiative this check's policy belongs to                                                         |
| `policy_id`        | `TEXT`      | The identifier of the policy this check is part of                                                                      |
| `enforcement`      | `TEXT`      | The enforcement level of the check. Can be one of `draft`, `score`, `block-pr`, `block-release`, `block-pr-and-release` |
| `status`           | `TEXT`      | The status of the check. Can be one of `pass`, `fail`, `pending`, `error`, `skipped`                                    |
| `failure_reasons`  | `TEXT[]`    | Array of human-readable reasons the check failed. Set to `NULL` if the check passed.                                    |
| `staleness`        | `INTERVAL`  | The time since the check was last evaluated. Set to `NULL` if the check is not stale.                                   |
| `manifest_version` | `TEXT`      | The version of the manifest used for this check                                                                         |

## Notes

* To get the set of checks for a given component version, you need to filter by `component_id`, and `git_sha`.
* The `committed_at` is guaranteed to be the same for a given `git_sha` of a `component_id`. This timestamp will also match entries in other views such as [`components`](/sql-api/views/components) and [`component_deltas`](/sql-api/views/component-deltas).
* While a certain `git_sha` for a component is being evaluated, the row may use a stale result from a previous version of the code. In such cases, the `staleness` column will be set to the time since the check was last evaluated.

## Usage examples

Retrieve the checks associated with the latest `git_sha` for a given component, ordered by status.

```sql
SELECT *
FROM checks_latest
WHERE component_id = 'github.com/foo/bar/buz'
  AND pr IS NULL
ORDER BY  CASE status
            WHEN 'fail' THEN 1
            WHEN 'error' THEN 2
            WHEN 'pending' THEN 3
            WHEN 'pass' THEN 4
            WHEN 'skipped' THEN 5
          END ASC;
```

Retrieve time series data of the number of checks that passed, failed, had no data, or errored out over time.

```sql
SELECT
  committed_at,
  SUM(CASE WHEN status = 'pass' THEN 1 ELSE 0 END) AS passed,
  SUM(CASE WHEN status = 'fail' THEN 1 ELSE 0 END) AS failed,
  SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
  SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errored,
  SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) AS skipped
FROM checks
WHERE component_id = 'github.com/foo/bar/buz'
  AND pr IS NULL
GROUP BY committed_at
ORDER BY committed_at ASC;
```

Retrieve the checks that are failing for all components with the tag `soc2`.

```sql
SELECT *
FROM checks_latest
WHERE component_id IN (
    SELECT component_id
    FROM components_latest
    WHERE 'soc2' = ANY(tags)
      AND pr IS NULL
  )
  AND pr IS NULL
  AND status = 'fail';
```

Retrieve the checks that are blocking PRs right now.

```sql
SELECT *
FROM checks_latest
WHERE pr IS NOT NULL
  AND status = 'fail'
  AND enforcement = 'block-pr';
```

Count the number of PRs that are blocked by checks for each domain.

```sql
WITH component_domains AS (
  SELECT
    component_id,
    domain
  FROM components_latest
  WHERE pr IS NULL
)
SELECT 
  domain,
  COUNT(DISTINCT pr) AS blocked_prs
FROM checks_latest
JOIN component_domains USING (component_id)
WHERE status = 'fail'
  AND enforcement = 'block-pr'
  AND pr IS NOT NULL
GROUP BY domain;
```

Total blocking checks for the domain `payments` over time.

```sql
WITH component_domains AS (
  SELECT
    component_id,
    domain
  FROM components_latest
  WHERE (domain = 'payments' OR domain LIKE 'payments.%')
    AND pr IS NULL
)
SELECT
  committed_at,
  COUNT(*) AS blocking_checks
FROM checks
JOIN component_domains USING (component_id)
WHERE status = 'fail'
  AND enforcement = 'block-pr'
  AND pr IS NOT NULL
GROUP BY committed_at
ORDER BY committed_at ASC;
```

All blocking failing checks of all PRs authored by Jane.

```sql
WITH janes_prs AS (
  SELECT component_id, pr
  FROM prs
  WHERE author_email = 'jane@example.com'
)
SELECT *
FROM checks_latest
JOIN janes_prs USING (component_id, pr)
WHERE status = 'fail'
  AND enforcement = 'block-pr';
```


# prs

Schema reference for the prs SQL view — pull request and merge request metadata including author, status, and latest commit details.

```
prs
```

The `prs` view provides information about component pull requests. The information in this view is largely mirrored from your Git platform, and is provided for convenience.

GitLab merge requests appear in this view alongside GitHub pull requests — a merge request's number is recorded in the same column as a pull request's.

## Schema

| Column                    | Type        | Description                                                                 |
| ------------------------- | ----------- | --------------------------------------------------------------------------- |
| `component_id`            | `TEXT`      | The identifier for the component - e.g. `github.com/foo/bar/buz`            |
| `pr`                      | `BIGINT`    | The pull request number                                                     |
| `pr_opened_at`            | `TIMESTAMP` | The UTC timestamp when the pull request was opened                          |
| `pr_status`               | `TEXT`      | The status of the pull request. Can be one of `open`, `closed`, or `merged` |
| `title`                   | `TEXT`      | The title of the pull request                                               |
| `author_name`             | `TEXT`      | The name of the author of the pull request                                  |
| `author_email`            | `TEXT`      | The email of the author of the pull request                                 |
| `latest_git_sha`          | `TEXT`      | The latest Git commit SHA of the pull request                               |
| `latest_commit_timestamp` | `TIMESTAMP` | The "committed at" UTC timestamp of the latest commit in the pull request   |
| `latest_committer_name`   | `TEXT`      | The name of the committer of the latest commit in the pull request          |
| `latest_committer_email`  | `TEXT`      | The email of the committer of the latest commit in the pull request         |

## Usage example

Retrieve PR information for all the PRs that have failing checks in a component.

```sql
WITH failing_prs AS (
  SELECT DISTINCT pr
  FROM checks_latest
  WHERE component_id = 'github.com/foo/bar/buz'
    AND pr IS NOT NULL
    AND status = 'fail'
)
SELECT *
FROM prs
WHERE component_id = 'github.com/foo/bar/buz'
  AND pr IN (SELECT pr FROM failing_prs);
```


# catalog

Schema reference for the catalog and catalog\_latest SQL views — full catalog JSON snapshots produced by catalogers, with history over time.

```
catalog
catalog_latest
```

The `catalog` view provides a historical timeseries of catalog JSONs generated by running catalogers over time. Each row is a complete catalog snapshot at a point in time.

The `catalog_latest` view holds only the current catalog. It is guaranteed to return at most one row, and no rows until Lunar Hub has published a configuration manifest.

## Schema

Both views share the same columns.

| Column         | Type        | Description                                                                                                                            |
| -------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `timestamp`    | `TIMESTAMP` | When the catalog last changed                                                                                                          |
| `captured_at`  | `TIMESTAMP` | When this version was recorded. `NULL` on the newest row when the current catalog has changed since the last recording. `catalog` only |
| `catalog_json` | `JSONB`     | The complete catalog data in JSON format                                                                                               |

## Notes

* The catalog JSON contains the full inventory of components and their metadata. The format is defined on the [Catalog JSON](/docs/catalog-json) page
* `catalog_latest` returns the same document as `lunar cataloger get-json`, so a query and the CLI always agree
* `timestamp` is when the catalog last **changed** — it does not advance when a cataloger runs and produces the same result
* **The history is sampled, not exhaustive.** Versions are recorded periodically, so several changes landing close together may appear as one row. A catalog that changes and changes back between recordings leaves no trace
* **History begins when your Lunar Hub was upgraded to record it.** Catalogs from before that point are not available here — use [`lunar cataloger get-json --ts`](/configuration/lunar-config/catalogers), which reconstructs any past catalog from the cataloger delta history
* Order the history by `captured_at`, not by `timestamp`. `timestamp` reflects when the catalog changed and can repeat across rows; `captured_at` always increases

## Usage examples

Get the current catalog:

```sql
SELECT timestamp, catalog_json
FROM catalog_latest;
```

Track how the component count has changed over time:

```sql
SELECT captured_at,
       jsonb_array_length(jsonb_path_query_array(catalog_json, '$.components.*')) AS components
FROM catalog
ORDER BY captured_at NULLS LAST;
```

List every component in the catalog with its domain and owner:

```sql
SELECT component.key              AS component,
       component.value->>'domain' AS domain,
       component.value->>'owner'  AS owner
FROM catalog_latest,
     jsonb_each(catalog_json->'components') AS component
ORDER BY component;
```

Fetch the catalog only when it has changed since you last looked. The `timestamp` filter is evaluated before the document is assembled, so a poll that finds nothing new is cheap — prefer this to re-fetching the whole document on a schedule:

```sql
SELECT timestamp, catalog_json
FROM catalog_latest
WHERE timestamp > '2026-08-10 12:00:00';
```

For per-component queries, the [`components` view](/sql-api/views/components) is usually a better fit than unpacking this document — it is row-shaped and indexed.


# bypasses

Schema reference for the bypasses SQL view — one row per policy bypass, with its scope, actor, reason, and expiry.

```
bypasses
```

The `bypasses` view provides information about policy bypasses: overrides of a PR or release block, created with `lunar policy bypass-pr` / `lunar policy bypass-release`, or by commenting `/lunar bypass: <reason>` on a blocked pull or merge request. Use it to see how often blocks are being bypassed, by whom, and for what reason.

Rows are never deleted. Expired and revoked bypasses remain in the view. The [`bypassed_checks`](/sql-api/views/bypassed-checks) view records which checks each bypass actually masked.

## Schema

| Column          | Type        | Description                                                                                                                                                                                                                               |
| --------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`            | `TEXT`      | The identifier for the bypass                                                                                                                                                                                                             |
| `component_id`  | `TEXT`      | The identifier for the component the bypass applies to - e.g. `github.com/foo/bar/buz`                                                                                                                                                    |
| `gate`          | `TEXT`      | The block being bypassed. Can be one of `pr` or `release`                                                                                                                                                                                 |
| `sha`           | `TEXT`      | The Git commit SHA the bypass is limited to. Set to `NULL` if it applies to every commit                                                                                                                                                  |
| `pr`            | `BIGINT`    | The pull request number the bypass is limited to. Set to `NULL` if it applies to every pull request                                                                                                                                       |
| `policy`        | `TEXT`      | The plugin (e.g. `sbom`) or single check (e.g. `sbom.no-critical-vulns`) the bypass is limited to. Set to `NULL` if it applies to every blocking check on the gate                                                                        |
| `reason`        | `TEXT`      | The reason given for the bypass                                                                                                                                                                                                           |
| `actor`         | `TEXT`      | Who created the bypass, as reported by the caller. Verified only where `verified_role` is set. See Notes                                                                                                                                  |
| `verified_role` | `TEXT`      | The role that was verified when the bypass was created, provider-qualified: e.g. `gitlab:maintainer` or `github:admin` for the comment break-glass. Set to `NULL` if none was, which is always the case for bypasses created from the CLI |
| `source`        | `TEXT`      | The surface the bypass was created from. Can be one of `cli`, `gitlab`, `github`, or `ui`                                                                                                                                                 |
| `created_at`    | `TIMESTAMP` | The UTC timestamp when the bypass was created                                                                                                                                                                                             |
| `expires_at`    | `TIMESTAMP` | The UTC timestamp when the bypass expires. `NULL` for comment-driven bypasses, whose bound is the commit rather than a clock: they cover a single SHA, and a new push re-arms the gate                                                    |
| `revoked_at`    | `TIMESTAMP` | The UTC timestamp when the bypass was revoked early. Set to `NULL` if it was not revoked                                                                                                                                                  |
| `revoked_by`    | `TEXT`      | Who revoked the bypass. Set to `NULL` if it was not revoked                                                                                                                                                                               |
| `active`        | `BOOLEAN`   | Whether the bypass is currently in effect. See Notes for the four ways a bypass stops being active                                                                                                                                        |
| `self_bypass`   | `BOOLEAN`   | Whether a request's own author bypassed their own change. Allowed, but recorded                                                                                                                                                           |
| `superseded`    | `BOOLEAN`   | Whether the bypass is pinned to a commit that is no longer its pull request's head. Always `false` on the release gate, and on a bypass with no `sha` or no `pr`                                                                          |
| `pr_closed`     | `BOOLEAN`   | Whether the bypass's pull request has merged or closed. Always `false` on the release gate, and on a bypass with no `pr`                                                                                                                  |

## Notes

* A bypass stops being `active` in four ways: it is revoked, its `expires_at` passes, its pull request moves to a new head commit (`superseded`), or its pull request merges or closes (`pr_closed`). The last two apply to the PR gate only. A `NULL` `expires_at` never lapses on the clock, so a commit-bound bypass leaves `active` through `superseded` or `pr_closed` instead. When Lunar cannot resolve a pull request's head or state, the row is left active rather than retired.
* `lunar policy bypass-ls --active` is narrower than this column: it filters on revocation and the clock only, so a superseded or merged bypass still lists there while reading `active = false` here.
* The `actor` column is not always authenticated. The CLI is authorized by token and does not verify who is running it, so `actor` is whatever the caller supplied. The `verified_role` column is set only when the bypass was created through a surface that checked a real role, which the comment break-glass does on both platforms: it records the commenter's GitLab access level or GitHub repository role. Filter on `verified_role IS NULL` to find bypasses created without a verified role.
* CLI-created bypasses always expire. An omitted `--for` resolves to the `bypass.max_duration` setting in `lunar-config.yml`, which also caps any explicit `--for`. `bypass.max_duration` itself defaults to two weeks. Comment-driven bypasses carry no expiry: they are bound to a single commit instead, which is the tighter limit.
* A bypass with a `NULL` `policy` covers every failing blocking check on its gate, including checks from policies added to the manifest after the bypass was created. Use the [`bypassed_checks`](/sql-api/views/bypassed-checks) view to see which checks it actually masked.
* Gates are independent. A policy that blocks both the PR and the release gate needs a separate bypass for each, and bypassing the PR gate does not affect the release gate.

## Usage examples

Retrieve the bypasses that are currently in effect.

```sql
SELECT component_id, gate, policy, actor, reason, expires_at
FROM bypasses
WHERE active
ORDER BY expires_at;
```

Count the bypasses created for each component over the last 90 days.

```sql
SELECT component_id, count(*) AS bypasses, count(DISTINCT actor) AS distinct_actors
FROM bypasses
WHERE created_at > now() - INTERVAL '90 days'
GROUP BY component_id
ORDER BY bypasses DESC;
```

Retrieve the policies that are bypassed most often.

```sql
SELECT COALESCE(policy, '(all blocking checks)') AS policy, count(*) AS times_bypassed
FROM bypasses
GROUP BY policy
ORDER BY times_bypassed DESC;
```

Retrieve the bypasses that have no verified role, which today is every bypass created from the CLI.

```sql
SELECT component_id, gate, actor, reason, created_at
FROM bypasses
WHERE verified_role IS NULL
ORDER BY created_at DESC;
```

Retrieve the active bypasses with the broadest scope: every blocking check, on every commit and every pull request of a component.

```sql
SELECT component_id, gate, actor, reason, expires_at
FROM bypasses
WHERE active AND policy IS NULL AND sha IS NULL AND pr IS NULL
ORDER BY expires_at DESC;
```

Retrieve the bypasses that were revoked early, and how long each one lasted.

```sql
SELECT component_id, actor AS created_by, revoked_by, revoked_at - created_at AS lifetime
FROM bypasses
WHERE revoked_at IS NOT NULL
ORDER BY revoked_at DESC;
```


# bypassed\_checks

Schema reference for the bypassed\_checks SQL view, an append-only log with one row per occasion a bypass let a failing check through.

```
bypassed_checks
```

The `bypassed_checks` view provides information about the checks each bypass has let through, with one row for every occasion it happened.

The [`bypasses`](/sql-api/views/bypasses) view records what a bypass was scoped to, while this view records what it actually masked. The two can differ, because a bypass with a `NULL` `policy` covers every blocking check on its gate, including checks from policies added to the manifest after the bypass was created.

## Schema

| Column         | Type        | Description                                                                             |
| -------------- | ----------- | --------------------------------------------------------------------------------------- |
| `id`           | `TEXT`      | The identifier for this masking occurrence                                              |
| `bypass_id`    | `TEXT`      | The identifier of the bypass that masked the check. Join to `bypasses.id`               |
| `component_id` | `TEXT`      | The identifier for the component whose check was masked - e.g. `github.com/foo/bar/buz` |
| `gate`         | `TEXT`      | The block the check was masked in. Can be one of `pr` or `release`                      |
| `sha`          | `TEXT`      | The Git commit SHA the masking happened on                                              |
| `pr`           | `BIGINT`    | The pull request number the masking happened on. Set to `NULL` outside a pull request   |
| `check_name`   | `TEXT`      | The fully qualified name of the check - e.g. `sbom.no-critical-vulns`                   |
| `masked_at`    | `TIMESTAMP` | The UTC timestamp of this masking                                                       |

## Notes

* The view is an append-only log. Every masking writes its own row and no row is ever updated, so "how many times" and "when was the last one" are `count(*)` and `max(masked_at)` at read time rather than stored columns.
* **A row is written where a gate's verdict is consumed to let something through, not where it is reported.** For the PR gate that moment is the merge: a bypassed pull or merge request that is still open, or that was closed without merging, has no rows even though the bypass was clearing its checks the whole time. For the release gate it is the `lunar policy ok-release` call. Posting a check run or status, re-evaluating a gate, and `lunar policy ok-pr` all write nothing.
* A bypass that was never exercised has no rows in this view.

## Usage examples

Retrieve the checks that a given bypass has let through.

```sql
SELECT check_name, sha, min(masked_at) AS first_masked, max(masked_at) AS last_masked, count(*) AS times
FROM bypassed_checks
WHERE bypass_id = '00000000-0000-0000-0000-000000000000'
GROUP BY check_name, sha
ORDER BY first_masked;
```

Retrieve the active bypasses that have masked at least one check, along with who created them.

```sql
SELECT b.component_id, b.actor, b.reason, bc.check_name, count(*) AS times, b.expires_at
FROM bypassed_checks bc
JOIN bypasses b ON b.id = bc.bypass_id
WHERE b.active
GROUP BY b.component_id, b.actor, b.reason, bc.check_name, b.expires_at
ORDER BY max(bc.masked_at) DESC;
```

Retrieve the bypasses that never masked anything. On the PR gate this includes every bypass whose request has not merged yet.

```sql
SELECT b.component_id, b.actor, b.reason, b.created_at, b.expires_at
FROM bypasses b
LEFT JOIN bypassed_checks bc ON bc.bypass_id = b.id
WHERE bc.bypass_id IS NULL
ORDER BY b.created_at DESC;
```

Retrieve the checks that were first masked more than two days after their bypass was created, which indicates a bypass that started covering something new.

```sql
SELECT b.component_id, bc.check_name, b.reason,
       min(bc.masked_at) - b.created_at AS granted_to_first_mask
FROM bypassed_checks bc
JOIN bypasses b ON b.id = bc.bypass_id
GROUP BY b.component_id, bc.check_name, b.reason, b.created_at
HAVING min(bc.masked_at) > b.created_at + INTERVAL '2 days'
ORDER BY granted_to_first_mask DESC;
```

Retrieve the most frequently masked checks across all components.

```sql
SELECT check_name, count(DISTINCT bypass_id) AS bypasses, count(*) AS total_maskings
FROM bypassed_checks
GROUP BY check_name
ORDER BY bypasses DESC;
```


# Product

Lunar has been in active development since 2024. Product release notes cover generally available platform capabilities and user-visible behavior across hosted, dedicated, and self-hosted deployments.

Use the annual pages in this section for the complete history. Product releases are grouped by general-availability date rather than a single product version.


# 2026

## 2026-09-15 <a href="#product-2026-09-15" id="product-2026-09-15"></a>

### Migrations and upgrade notes

* A `customization.checks_template` now renders once for the whole GitHub checks report instead of once per component. The template receives every matched component and pull-request scope in `.Components` — each carrying the component's full and short name, its repository-relative path, details URL, check sections, bypassed checks, evaluation state, and a one-line verdict summary — and it owns the report title, the component headings, and the evaluation notices that Lunar previously assembled around the rendered output. A template that reads the top-level `.Component`, `.DetailsURL`, `.Sections`, or `.Bypassed` still renders a report covering a single component scope, as GitLab merge-request notes and single-component pull requests do; on a GitHub report covering several scopes, Lunar logs the error and posts the built-in report instead, so move those accesses inside `{{range .Components}}` to keep customizing reports on a pull request that matches more than one component. When a report exceeds GitHub's size limit, Lunar re-renders it with `.Compact` set, so a custom template can supply its own condensed layout; the built-in layout then keeps each component's verdict and details link. The built-in report leaves components with nothing to report out of pull-request comments, while a custom template receives them and decides which to show. The full field contract and an example are documented under `customization.checks_template` in the Lunar configuration reference. See [Customizable checks report templates](#product-checks-report-templates).

### Security

* Collection data written by a CI job that authenticates with its forge-issued OIDC token is now stored together with the job's ref and whether the forge reports that ref as protected, and a commit Lunar knows to be on the component's default branch ignores records written from an unprotected ref when its Component JSON and its policy input are assembled. This closes a gap where a branch cut from the default branch with no new commits shares the default branch's head commit, so a job running on the unprotected branch could write collection data that the default branch's status then read. Records carrying no ref information — those written by the Hub's own scheduled runs, by a static hub token, by an out-of-band `lunar collect`, or before this release — count exactly as they did before, and nothing is excluded away from the default branch, so a pull request's data still comes from its own pipeline. See [CI job identity for GitHub Actions and GitLab CI](#product-ci-job-identity).

### Features

#### CI job identity for GitHub Actions and GitLab CI <a href="#product-ci-job-identity" id="product-ci-job-identity"></a>

A GitHub Actions or GitLab CI job can now authenticate to the Hub with the OIDC token its forge issues it, instead of carrying the shared Hub service token. The Hub verifies the token against the forge's published JWKS and against the audience it is configured with, and derives the job's identity from the token's claims: the repository, the actor who triggered it, the ref, and the commit.

Because a forge expires such a token within minutes, the Hub trades a verified token for a Hub session that lasts the job. The session carries the same coordinates, so a job's authority is confined to its own repository and its own commit — collecting and reading for the commits its ref binds it to — while everything outside that, such as creating a gate bypass, stays refused. A job whose repository is not yet in the catalog is refused until it is cataloged.

To use it, the job requests an OIDC token for the Hub's audience and passes it as `LUNAR_HUB_TOKEN`; the CLI performs the exchange on its own. Trusted issuers follow the forge configuration the Hub already has: GitHub Actions on each configured GitHub host, including GitHub Enterprise Server, and GitLab CI on each configured GitLab host. Buildkite jobs continue to use the service token, because a Buildkite OIDC token does not name the forge repository the build belongs to.

#### Choosing which configuration file a Hub loads <a href="#product-configuration-entry-point" id="product-configuration-entry-point"></a>

The in-repo path on a configuration URI now selects which configuration file is loaded, so one repository and one branch can serve several Hubs. `lunar hub pull github://acme-corp/lunar-repo/lunar-config.dev.yml@main` loads `lunar-config.dev.yml`, while `lunar hub pull github://acme-corp/lunar-repo@main` keeps loading `lunar-config.yml` at the repository root exactly as before. A path naming a directory resolves to the `lunar-config.yml` inside it, so a `configs/prod/` layout works too, and the GitLab form takes the path after the usual `/-/` marker.

Each entry point reads its own fragments: the fragment directory is the entry point's name with the extension replaced by `.d`, so `lunar-config.dev.yml` merges `lunar-config.dev.d/` and never the default's `lunar-config.d/`. A relative `uses:` resolves from the entry point's own directory — for the sibling-file layout that is the configuration root, so both entry points import a shared `collectors/` and `policies/` tree with the same `./collectors/foo`. Script provenance stays repository-root-relative, so a link in a pull-request comment opens the file that actually declared the script, fragment directory included.

A path that names nothing, climbs out with `..`, is absolute, or resolves through a symlink outside the repository fails the pull instead of falling back to the root configuration, and it fails even at a commit the Hub has already pulled.

A Hub loads one entry point at a time, and a configuration version is the commit alone, so pointing an existing Hub at a different entry point takes effect on its next new commit.

#### Per-person SQL API credentials <a href="#product-per-person-sql-api-credentials" id="product-per-person-sql-api-credentials"></a>

Someone signed in to the Hub with `lunar login` now receives a SQL API credential of their own instead of the shared read-only user. `lunar sql connection-string` mints a personal Postgres role for them, a member of the shared SQL API role, so it carries exactly the same read-only access to the SQL API views and the same session limits. The role expires after the lifetime the Hub is configured with — 30 days by default — and every call rotates its password and extends the expiry, so a person has one live credential at a time and re-running the command is how a lost or leaked one is replaced.

The Hub service token still receives the shared connection string, unchanged, so BI tools and other static integrations are unaffected. CI jobs cannot obtain a SQL credential at all.

Who holds SQL access is now answerable. `lunar sql credentials ls` lists every personal credential the Hub has issued — active, expired, revoked, or dropped — and the same record is queryable through a new `sql_credentials` view in the SQL API. A person's access is ended with `lunar sql credentials revoke <login>`, which disables their role and terminates its open sessions immediately; `lunar logout` does not, because the SQL credential is independent of the CLI session. Because each person queries under a role of their own, `pg_stat_activity` and the Postgres logs name them rather than one shared user.

On a self-hosted Hub an operator can turn personal credentials off, in which case signed-in people are refused and told to use the service token.

#### Signing in to Lunar as yourself <a href="#product-personal-sign-in" id="product-personal-sign-in"></a>

A person can now use Lunar under their own identity instead of the shared Hub service token. `lunar login` signs in with a GitHub or GitLab account on a forge host the Hub accepts, and the Hub issues a short-lived session only after checking at the forge that the presented token was issued to the Hub's own OAuth app — a token from any other application, including a personal access token, is refused. The Hub keeps neither the access token nor the refresh token.

Every request made with that session is authorized by the person's own role on the repository the request touches, as the forge reports it, so a command run by a signed-in engineer is allowed or denied according to their forge permissions rather than a single credential shared by everyone. `lunar whoami` reports the identity the Hub established for the current credential — a signed-in person, a CI job, or the service token.

Sessions are short-lived: at most 12 hours, and shorter when the forge token expires sooner. The CLI renews both the forge token and the session before they expire, and `lunar logout` forgets the login and revokes the forge token where the forge allows it. Personal sign-in is available on an installation whose operator has registered the forge OAuth apps to accept logins from; the service token and CI job credentials continue to work unchanged.

### Improvements

* Refreshing the materialized data behind the SQL API `components` and `components_latest` views — and the dashboards built on them — no longer stalls on the step that removes the rows it is about to rewrite. That step previously combined two match conditions into one delete per table, which made PostgreSQL scan the whole projection and, on a large refresh, rescan the staged rows repeatedly; each table's rows are now removed by two separate statements in the same transaction, so each can use its index. In a synthetic test with 300,000 projection rows and 50,000 staged rows, the two clears completed in about 0.2 and 0.4 seconds where they previously ran past a 12-second limit. Component data reaches the views and dashboards sooner on large installations, and query results are unchanged.
* Refreshing the materialized data behind the SQL API `prs` view no longer scans the whole stored pull-request projection to remove the rows it is about to rewrite. A narrow refresh — one triggered by activity on a few pull requests — now looks up only the affected `(component, pull request)` rows through the existing index, which took 1.75 ms rather than 364 ms in a benchmark with 300,000 stored rows and 100 rows to replace. Rows that stop deriving, for example after a repository association is removed from a component, are still cleared, and the view's contents are unchanged — only cheaper to keep current.
* A new `Earthly Lunar` check run or Lunar results comment on GitHub is now written by the App with the lowest App ID among those configured for the repository's host and owner, instead of the first App in configuration order, so reordering the configured Apps or adding another one leaves the posting identity unchanged. On an installation where the previously first-configured App is not the lowest-numbered one, new posts now come from a different App, which matters where a repository requires a particular App as the source of its check; adding a lower-numbered App or removing the selected one changes only the App used for new posts and never reassigns an existing one. Read spreading across the App pool and clone-token selection are unchanged, and an organization with a single App behaves exactly as before. See [Multiple GitHub Apps for one organization](#product-multiple-github-apps-per-owner).
* The navigation table on the pull-request details dashboard now heads its link to the change request with the forge's own noun — "PR URL" for GitHub and GitHub Enterprise Server components, "MR URL" for GitLab ones — instead of "Repo URL", which named the repository while the link opened the pull or merge request.
* The background refresh that materializes the data behind the SQL API `components` and `components_latest` views — and the dashboards built on them — no longer repeats the same newest-blob lookup for every historical version of a component. Each component-name and commit pair is now resolved once, and Component JSON is fetched only after the candidate rows have been deduplicated, so the refresh cost no longer multiplies with the number of Lunar configuration versions a component has accumulated. On a synthetic dataset of 100 commits across 200 component versions, the component derivation took 311 ms instead of 10.8 s and the hour-window derivation 440 ms instead of 11.1 s, returning identical rows; these are benchmark figures rather than production latencies.
* The Script runs dashboard loads much faster when filtered by component or commit SHA on large installations. The component filter is now applied as separate equality conditions on the indexed owner and repository columns, the SHA filter is applied while the runs are scanned rather than afterwards, and the lookup that resolves each run's component no longer scans the whole components table — together taking the runs table and its pagination count from several seconds to a few milliseconds on an installation with 63 million recorded script runs.
* A gate bypass created or revoked from the CLI by a logged-in user is now recorded against the identity the Hub authenticated rather than a self-declared name: the ledger's `actor` is that login, and `verified_role` is the role the Hub verified on the component (`github:maintain`, `gitlab:maintainer`), so the row reads as an identity-checked override wherever bypasses are audited — `lunar policy bypass-ls`, the SQL API `bypasses` and `bypassed_checks` views, and the dashboards. Passing `--actor` naming anyone other than the logged-in user is rejected, and omitting it records the bypass as that user. A bypass created with the Hub's service token behaves exactly as before: the actor is whatever the caller supplied, unverified, with `verified_role` left unset — which is how an audit tells a vouched-for override from an unverified one — and it is now rejected outright if no actor is supplied. See [Signing in to Lunar as yourself](#product-personal-sign-in).

### Bug fixes

* A component's rows can no longer disappear from the materialized data behind the SQL API `checks` and `checks_latest` views, and the dashboards built on them, while a newly created version of that component is still waiting for its repository association to be recorded — the window that follows a Lunar configuration update. The background refresh scoped to a component picked a representative version without regard to whether it was associated with a repository yet, so it derived no rows for the component name while still clearing the rows it already had, and a later refresh of the same component repeated the loss instead of restoring it. The refresh now chooses a version that is associated with a repository, so existing coverage survives and is restored on the next refresh.
* Components and domains dropped from the catalog by a cataloger re-merge are no longer counted on the Home, Domains, Domain details, and Initiatives dashboards. Because such entries are only marked as removed rather than deleted, the scorecards behind those pages kept counting them, so a reduced catalog still showed the pre-reduction component and domain counts, tags, passing-check totals, and score, while the Catalogers dashboard already showed the reduced numbers. The initiative passing and total component counts, and the domain hierarchy they are built from, are corrected the same way — the hierarchy previously also spanned every Lunar configuration version ever published rather than the current one. The corrected numbers appear on the next periodic refresh of the data behind these panels; no action is required on upgrade.
* An existing `Earthly Lunar` check run or Lunar results comment on GitHub is now updated with the App that created it, resolved from the creator GitHub reports on the resource, rather than with whichever App the Hub would use for a new post. On an organization configured with more than one GitHub App, an edit attempted by a different App was rejected with `403 Invalid app_id`, which could leave the check run or comment stale or, for a check run, produce a second run of the same name at the commit. Both lookups now page through every check run and every comment on the pull request, so a post that has scrolled onto a later page is found and updated instead of duplicated, and bypass and revoke writes follow the originating App too — revoking a completed run creates its replacement under the same App. If the creator an existing post reports is missing, or belongs to an App no longer configured for that organization, Lunar reports an error for that resource instead of editing it with the wrong credentials. See [Multiple GitHub Apps for one organization](#product-multiple-github-apps-per-owner).
* On GitHub, the Lunar pull-request comment and the `Earthly Lunar` check run now cover every component a pull request matches, instead of reporting whichever component posted last. The components a commit's collection was dispatched for are all included: each appears in the comment under its own heading, in a stable order, with its own details link to that component's dashboard, and the check run's title, summary, and conclusion are computed from the required checks of all of them together. A component that passes, or that has no applicable policy at all, can therefore no longer overwrite a sibling component's failing required checks and leave a blocked pull request reporting success with no trace of the failures — the case that made the check unusable as a merge gate on a monorepo. Required-ness is resolved per component, as is any active bypass, so a bypass granted for one component no longer clears another component's failures, and unbypassed failures keep blocking. While a matched component is still being evaluated the report says so and is refreshed once its results arrive. A pull request matching a single component keeps the report layout it had, and GitLab merge requests are unchanged.
* A failure posting one surface of a commit's results no longer stops the rest from being posted. Previously the first failure ended the posting run, so a problem writing the Lunar results comment on a pull request could leave the `Earthly Lunar` merge-gate check run unwritten; each surface now posts independently and every failure is still reported and retried.
* The Component filter on the Script runs dashboard now lists the components that have script runs, instead of staying empty. The query behind the dropdown scanned the whole script-run history on every page load, so on a large installation it was cut off by the request timeout before returning anything; the list is now read from the per-repository run state, which holds the same owner and repository spellings the listing filters on.

## 2026-09-08 <a href="#product-2026-09-08" id="product-2026-09-08"></a>

### Bug fixes

* The `Earthly Lunar` GitHub check run and GitLab commit status are again created only once Lunar has policy results for a commit, instead of being written as soon as collection starts. On a pull request where no visible policy check applied, the running placeholder was never replaced by a verdict, so the check stayed in progress indefinitely — and blocked the merge wherever `Earthly Lunar` is a required check. See [the running Lunar check](#product-2026-09-07).
* A script run — a collector, policy, or cataloger — is no longer failed outright when its container image cannot be pulled for a temporary reason, such as registry pull throttling or a registry that is briefly unreachable. Lunar now retries the run on a fresh pod within its existing five-attempt budget, so a momentary registry problem no longer cancels every run packed onto the same pod. An invalid image name or a container configuration error is still treated as permanent and fails the affected runs immediately.

## 2026-09-07 <a href="#product-2026-09-07" id="product-2026-09-07"></a>

### Features

#### Instance-wide GitLab tokens and token pools <a href="#product-gitlab-host-wide-token-pools" id="product-gitlab-host-wide-token-pools"></a>

A GitLab access token no longer has to be bound to a group. A token configured without a group is host-wide: it serves every group on its host that no group-bound token covers, so one instance service account can cover a whole GitLab Dedicated or self-managed instance, and a new group is brought into scope by inviting the account to it rather than by changing configuration. Group-bound tokens still take precedence, and the most specific matching group still wins, so a group that needs its own bot keeps it.

Several tokens configured for the same scope now form a pool instead of being rejected. Lunar spreads read traffic across the pool — project metadata, default branches, commit and merge-request listings, a merge request's changed files, and the tier probe behind the merge gate — so a busy group or a busy instance draws on every account's GitLab rate limit. Everything Lunar writes, or that has to keep one identity, stays on the first token listed for the scope: commit statuses, merge-request notes, project badges, webhook and merge-gate provisioning, the webhook signing secret, and Git clones. A scope with a single token behaves exactly as before.

Every account in a pool should hold the same role, so that what Lunar can do in a group does not depend on which token served a call.

### Improvements

* The check that decides whether a commit's code collectors have finished — the check a policy run's input data waits on — no longer reads that commit's run history twice. On a commit whose runs have accumulated across many Lunar configuration versions, for example a commit reused by hundreds of successive component generations, the check previously scanned the whole population once to find the latest code run and again to evaluate the current collection cycle, and on the largest cases it took long enough that a policy run's request for its input data timed out and was reported as an internal error. It now probes a bounded set of runs, reads the matching code runs once, and derives the latest six-hour collection cycle in the same pass: measured on a production-scale installation, a commit with roughly 690,000 candidate runs dropped from about 1.4 seconds to about 0.7 seconds, while an ordinary commit stayed near a millisecond. Runs recorded under superseded or removed component identities are still counted, so the answer the check gives is unchanged.
* The `Earthly Lunar` GitHub check run and GitLab commit status now appear as soon as Lunar starts collecting for a commit, reading "Earthly Lunar policy is running" and pointing at where the results will appear, instead of showing nothing until the commit's first policies finish. The running state is written only when Lunar has no status at that commit yet, so a verdict already posted is never flipped back to running, and a forge error while writing it does not stop the collection.
* The quiet-until-first-failure setting for the Lunar results comment is now spelled `customization.pr_comments.mode: only_failures`, matching the snake\_case used by every other key and value in the `customization` block. The original `only-failures` spelling remains accepted — in the Lunar configuration, in the generated JSON schema used by schema-aware editors, and in configurations already pulled — so no change is required, and it is treated exactly as `only_failures`. The documentation now shows only the underscore spelling. See [quiet-until-failure results comments](#product-2026-08-18).
* The quiet-until-first-failure setting for the Lunar results comment is now spelled `customization.pr_comments.mode: only_failures`, matching the snake\_case used by every other key and value in the `customization` block. The original `only-failures` spelling stays accepted — in the Lunar configuration, in the generated JSON schema used by schema-aware editors, and in configurations already pulled — and is treated exactly as `only_failures`, so no change is required. The validation error for an unknown mode and the documentation now show only the underscore spelling. See [quiet-until-failure results comments](#product-2026-08-18).

### Bug fixes

* `lunar policy bypass-ls` now reports a bypass as active only when it is still in effect on every bound the bypasses dashboard and the SQL API `bypasses` view already applied. A PR-gate bypass pinned to a commit the pull or merge request has since moved past, and one whose pull or merge request has merged or closed, are no longer listed as active, and `--active` no longer returns them — previously the listing considered only the revocation and expiry recorded on the bypass itself, so a commit-bound break-glass on a finished merge request could be reported as active indefinitely. Both surfaces now derive liveness from a single definition in the Hub, so the command and the dashboard agree row for row. What a bypass masks at the merge or release gate is unchanged.
* A GitHub Actions workflow run that has finished on GitHub is no longer left recorded as queued or in progress, which previously kept every policy for the affected commit pending indefinitely and made `lunar policy ok-release` poll until its timeout. Two things caused it. Deliveries for the same run — `queued`, `in_progress`, and `completed` — are handled concurrently and can finish out of order, and the later non-terminal delivery overwrote the terminal one; a completed run attempt is now terminal, and a late non-terminal delivery for it changes neither its status nor its timestamp. Separately, a `completed` delivery could reach the Hub and never be recorded, because component matching, the affected-file lookup, or commit enrichment failed or returned early first; the run's status is now stored before any of those steps, so the delivery closes an already-tracked run either way. A GitHub retry is unaffected: GitHub increments the run attempt, so the retry gets its own row and release doneness keeps following the highest attempt. Buildkite builds, whose jobs are retried within one build rather than as a new attempt, can still return to running after completing. Runs already stuck non-terminal before this release are not repaired by it.
* `lunar policy ok-release` and `lunar policy ok-pr` no longer wait for policy results the gate can never act on. Previously, if any policy evaluated at the commit had not finished — including policies at `score`, `report-pr`, or, for a release gate, `block-pr` — the gate kept polling until its `--timeout`, even though only checks at `block-release` or `block-pr-and-release` (plus `block-pr` for a PR gate) can decide the verdict. The gate now answers immediately, with a message that no policies gate the check, when the Lunar configuration applies no gating policy to the component. It still waits whenever a gating policy applies, whenever a result already recorded at the commit carries a gating enforcement level, and whenever Lunar cannot determine which policies apply — so an unresolvable configuration keeps blocking rather than releasing. This extends the earlier fix for gates with nothing to wait for; see [gates settling immediately when no policy blocks](#product-2026-07-28).
* Under `customization.pr_comments.mode: only_failures`, a check that is still waiting on data no longer posts the results comment — only a failing, erroring, or unknown check does. Previously a pending check counted as not passing, so on a component whose data arrives from CI the first evaluation almost always posted the comment and the quiet mode never engaged there. Pending checks remain visible in the `Earthly Lunar` GitHub check run and GitLab commit status, an existing comment keeps updating as before, and `always` mode is unchanged.
* A policy run can no longer be discarded because the download link for its input bundle had already expired. Since the Hub began sharing its cache of policy input bundles across replicas, the cached entry held a signed download URL; when that URL was signed just before the Hub's temporary object-storage credentials rolled over, every run that read the entry for the rest of its lifetime failed to fetch the bundle with an expired-token error, and because each retry fetched the same cached URL, the affected evaluations were dropped rather than delayed. The Hub now caches the bundle's storage location instead and signs a fresh download URL for every request, so the bundle is still built and uploaded only once.
* Setting `customization.pr_comments.mode: only_failures` in the Lunar configuration now changes when the results comment is posted. The mode was recorded when the configuration was synced but never read back when the comment was posted, so every installation behaved as `always` and the comment was posted on pull and merge requests whose checks all passed. See [quiet-until-failure results comments](#product-2026-08-18).
* The `meta` annotations and the `failureText` template written on a policy's `uses:` entry in `lunar-config.yml` now reach every sub-policy the entry imports, as the configuration reference describes. Previously both fields were dropped when the imported policies were loaded, so only policies declared inline in the Lunar configuration stored queryable `meta` on the SQL API `policies` view or rendered custom failure prose in the checks report; an import kept an empty `meta` and the default assertion bullets. Where a key is set both at the import site and by the imported plugin, the import site's value wins and the plugin's other keys are kept; an entry that sets neither field leaves the plugin's own values untouched. See [Queryable policy annotations with `meta`](#product-2026-08-25) and [Custom failure text per policy](#product-policy-failure-text-templates).
* Under `customization.pr_comments.mode: only_failures`, a check that is still waiting on data no longer posts the results comment — only a failing, erroring, or unknown check does. Previously a pending check counted as not passing, so on a component whose data arrives from CI the first evaluation almost always posted the comment and the quiet mode never engaged there. Pending checks stay visible in the `Earthly Lunar` GitHub check run and GitLab commit status, an existing comment keeps updating as before, and `always` mode is unchanged.
* Setting `customization.pr_comments.mode: only_failures` in the Lunar configuration now changes when the results comment is posted. The mode was recorded when the configuration was synced but never read back when the comment was posted, so every installation behaved as `always` and the comment appeared on pull and merge requests whose checks all passed. See [quiet-until-failure results comments](#product-2026-08-18).

## 2026-09-02 <a href="#product-2026-09-02" id="product-2026-09-02"></a>

### Improvements

* Lunar Hub's conditional requests to the GitHub API now keep saving rate-limit budget on repositories it revisits regularly: a cached response's retention is renewed every time GitHub confirms the resource is unchanged, instead of expiring a fixed period after the original full response and forcing a full re-fetch of a resource that never changed. Cached entries for repositories that stop being polled still age out as before, and the renewed entry keeps GitHub's live rate-limit headers.
* A GitHub pull-request event that never triggers collection — labeling, editing, assigning, requesting a review, marking ready for review, and the other metadata activity types — no longer makes Lunar list the pull request's changed files and commits on GitHub, so those deliveries stop consuming GitHub API rate-limit budget on installations with busy repositories. Lunar still refreshes the stored metadata of a pull request it already tracks, such as its title and state, so the dashboards and the SQL API `prs` view stay current; a pull request Lunar is not already tracking is no longer recorded from a metadata event, since which components a pull request touches is established when it is opened, synchronized, or reopened.
* Lunar now reuses the list of files a GitHub pull request changes across every event it receives for the same base and head commit — the pull-request event, the workflow-run events that follow it, and any redelivery — instead of listing the pull request's files from GitHub again each time. The stored result is identified by the two commit SHAs, so it describes exactly that diff and cannot go stale, and reusing it reduces the GitHub API rate-limit budget Lunar spends on repositories with active pull requests. Buildkite builds still list the files each time, because their payload does not carry the base commit needed to identify the diff.
* The Hub's periodic repository sync no longer re-reads a GitHub branch's commit history when nothing has been pushed to it. Once a sync has walked a branch to completion, later passes confirm the branch head with a single lightweight Git-ref lookup and skip the commit listing entirely, instead of always making one commit-listing call plus one head lookup. That ref lookup is served through the Hub's conditional-request (ETag) cache, so on an unchanged branch it revalidates as a rate-limit-free response and consumes no GitHub REST rate-limit budget once the cache has warmed; with personal-access-token authentication or ETag caching turned off, the unchanged branch costs one request instead of two. A branch whose head has moved, or one the Hub has no completed walk recorded for, takes the full history walk exactly as before, so gaps left by a missed webhook delivery still heal. Every repository takes one normal, complete sync after the upgrade before the shorter path applies, and GitLab-hosted repositories are unaffected.
* The Hub's periodic repository sync on GitHub no longer asks GitHub for the commit list of every open pull request on every reconciliation pass. Once a sync has persisted and associated a pull request's complete commit listing, Lunar records the base branch and head commit it did that for, and a later sync whose pull request still reports the same base branch and head skips the commit request while still refreshing the pull request's mutable details such as title, state, and author. A changed base branch or head, a pull request Lunar has not fully reconciled yet, or a commit listing GitHub could have truncated all fall back to the full listing, so the sync keeps healing anything a missed webhook left behind. Existing pull requests are reconciled in full once after the upgrade before the skip applies to them, and on installations with many open pull requests this removes a large share of the GitHub API requests the sync consumes.
* The SQL API views — `checks`, `checks_latest`, `components`, `components_latest`, `prs`, `policies`, `domains`, and `initiatives` — are now read directly from Lunar's materialized tables on every installation, instead of deriving recent rows from raw commit and run history at query time and combining them with an older projection. The materialized data is kept current in the background as collections and policy runs land, so the views return the same rows for far less work; serving the current check slice from the stored projection measured 1.28 seconds against 3.35 seconds on an installation with roughly 30,000 components. Dashboard panels that read those views benefit the same way.

### Bug fixes

* A failing check's assertion messages in the checks report posted with check results — the GitHub Check Run text, the GitHub pull-request comment, and the GitLab merge-request note — now appear in the order the policy asserted them, both in the default bullet list and where a custom failure-text template ranges over `failure_msgs`. Previously the messages of one check were stored without a recorded position and came back in an arbitrary order, so a policy asserting one condition and then another could render them the other way round. Checks recorded before this release keep whatever order they already had, since their original order was never stored.
* A script run that takes longer than 30 minutes now has its exit code, logs, and results recorded. Previously the monitoring of a script pod gave up after 30 minutes, so a longer collector, policy, or cataloger run was reported as failed with exit code -1 even when the script itself was still running or had already succeeded. Monitoring now lasts as long as the run's own execution budget.
* A component that has never had any activity recorded against it — common on an installation whose Lunar configuration and repositories rarely change — could be absent from the materialized data behind the SQL API `checks` and `checks_latest` views and the dashboards built on them, so it rendered as a component with no checks rather than reporting an error. The nightly deep reconcile now also walks the full component list and queues a refresh for any component that holds no materialized check rows, so such gaps close on their own within a day instead of persisting until the component next sees activity.
* Reading a pull request's changed files and its commits from GitHub is now spread across an organization's GitHub App pool, along with the other repository and workflow reads. These paginated reads were still pinned to the first App listed for the organization, so on a high-activity organization they consumed that one installation's REST API rate-limit budget instead of the pool's; identity-sensitive writes such as check runs and pull-request comments continue to use the first App. An organization with a single App behaves exactly as before. See [Multiple GitHub Apps for one organization](#product-multiple-github-apps-per-owner).
* A GitHub pull request with more than 100 commits now has every one of its commits ingested and associated with the pull request before its head commit is processed. Lunar previously read only the first page of the pull request's commits, so on a large pull request the head commit announced by the webhook could be dispatched for collection and policy evaluation before it had been stored, and the checks for that pull request failed with `failed to get commit: not found`. GitHub caps that listing at 250 commits, and a listing that is capped or that does not end at the announced head is still left to the Hub's periodic reconciliation rather than being treated as complete.
* The Queued tab on the runs and script-runs dashboards now honors the dashboard's filters, instead of showing the whole fleet's backlog beside filtered completed runs. Component, script name, script name prefix, script type, SHA, and pull request now scope the queued rows, the pagination count, and the "Queued (N)" count in the tab title, so the count always agrees with the rows on screen; arriving from a component, policy, collector, or pull-request page therefore lands on a Queued tab already scoped to that context, including monorepo sub-components. The status and rerun filters remain deliberately without effect, because a queued run has not started and has no terminal status to match or rerun to deduplicate. Queued rows carrying a pull request now link to the pull-request details dashboard rather than the component details, since the pull request is now read from jobs awaiting dispatch instead of being discarded.
* Reopening a closed GitHub pull request now dispatches code collection for the components its changes touch, so its checks are re-evaluated instead of waiting for the next push to the branch. This also covers a head commit that moved while the pull request was closed, which previously left the reopened pull request showing results for the old head.
* A script run — a collector, policy, or cataloger — whose container is killed for exceeding its memory limit (an `OOMKilled` termination, or exit code 137) is now retried on a fresh pod instead of being recorded as a failed run. The same now applies when a run's outcome cannot be observed at all: if its exit code cannot be read or its results cannot be submitted, every run sharing that pod is retried rather than accepted as failed, because there is no way to tell which individual results were lost. A retry reuses the run's existing identity, and a script that exits non-zero on its own is still treated as a genuine failure and is not retried.
* A collector, policy, or cataloger run executing in Kubernetes is no longer failed and retried as a whole batch when the Kubernetes watch used to observe its pod is interrupted. An expired resource version (`410 Gone`) is now relisted and a watch stream closed by the API server is reconnected with bounded backoff, instead of ending observation and recording the affected runs with a sentinel exit code before the batch is retried on a fresh pod. A run's exit code is still reconciled with a direct read of the pod when the watch ends, and a deleted pod, a missing container, or an expired context remain terminal and are reported with the same diagnostics as before.
* A pull request whose diff reaches GitHub's 3,000-file listing limit is no longer scoped against a possibly incomplete file list when the event carries no authoritative changed-file count — a workflow-run delivery, or a Buildkite build. Lunar now treats such a listing as unknown and collects every component the pull request could touch, as it already did when the reported count showed the list was incomplete, instead of silently skipping components whose paths fell beyond the first 3,000 files.

## 2026-08-25 <a href="#product-2026-08-25" id="product-2026-08-25"></a>

### Breaking changes

* The SQL API `prs` view now reports `pr_status` with one vocabulary for every forge — `open`, `closed`, or `merged`, as the view's documentation already described — instead of each forge's raw value. A merged GitHub pull request reports `merged` rather than `closed`, and an open GitLab merge request reports `open` rather than `opened`, so queries, reports, and dashboards that match the previous per-forge values have to be updated. GitHub pull requests merged before this release keep reporting `closed`, since the stored data does not distinguish them from abandoned ones.
* A Lunar configuration that sets a wildcard in `components.<id>.branch` — for example `branch: release-*` — now fails the configuration sync with an error naming the component and the value. `branch` is a single branch name: name the branch explicitly, or omit it to track the repository's default branch. A pattern was previously honored only in part — it matched when deciding which components a push reached, while everything downstream treated the component as tracking no resolvable ref, so the component's current commit could only ever be advanced by a push to a matching ref and had no way to recover from a missed one. Wildcards in the component key itself and in `paths`, where they are documented, are unaffected.

### Features

#### Multiple GitHub Apps for one organization <a href="#product-multiple-github-apps-per-owner" id="product-multiple-github-apps-per-owner"></a>

A Lunar Hub can now use more than one GitHub App installation for the same organization. Lunar spreads repository and workflow reads across the Apps, so a high-activity organization can use each installation's independent REST API rate-limit budget instead of exhausting a single installation.

Check runs and pull-request comments stay on the first App listed for the organization, because GitHub requires the App that created one to update it. Lunar spreads repository webhook setup and removal across the pool along with the reads. An organization with one App behaves exactly as before. See [Avoid GitHub rate limiting](/install/git-platforms/github#avoid-github-rate-limiting) for setup instructions.

#### Custom failure text per policy <a href="#product-policy-failure-text-templates" id="product-policy-failure-text-templates"></a>

A policy entry in `lunar-config.yml` can now set `failureText`, a Go `text/template` that replaces what a failing check writes into the checks report — the GitHub Check Run text, the GitHub pull-request comment, and the GitLab merge-request note — so a plugin's wording can be wrapped in your own prose and links without forking the policy.

The template renders as Markdown and can read three namespaces: `{{ .check.failure }}` for the whole default bullet list of failing assertions as one block, `{{ range .check.failure_msgs }}{{ .message }}{{ end }}` for the assertions one at a time, `{{ .check.name }}`, `{{ .meta.<key> }}` for a value from the policy's `meta`, and `{{ .policy.name }}` and `{{ .policy.description }}`. Because Go's template grammar reads a hyphen as subtraction, a hyphenated `meta` key has to be read with `{{ index .meta "enforce-control" }}`.

Setting `failureText` is a full override rather than an addition: a template that references neither `{{ .check.failure }}` nor `{{ .check.failure_msgs }}` produces a comment that says the check failed without saying why. Omitting the field keeps today's built-in assertion list, and a template that fails to parse or renders to nothing falls back to that same built-in list, so a typo costs the custom wording and never the failure detail. The field applies to every sub-policy in the entry it is written on, and it changes only the failing check's own block within the report — the report as a whole is still governed by [Customizable checks report templates](#product-checks-report-templates).

* The final verdict block `lunar policy ok-release` prints can now be customized: set `customization.ok_release_template` in the Lunar configuration to the path of a Go `text/template` file in the configuration repository, and Lunar renders it in place of the built-in summary line and check lists, so release pipeline logs can carry your own context such as runbook links or escalation instructions. The template receives the component name, the commit SHA, the verdict, the failing checks (name, policy, status, enforcement level, and failure messages) and the bypassed checks (actor, reason, and expiry or commit-bound). It is validated when the Lunar configuration is pulled — against a passing verdict, a passing verdict with bypassed checks, and a blocked verdict, each of which must render non-empty output of at most 64 KiB — and a stored template that fails at gate time falls back to the built-in output instead of blocking the release check. The template controls presentation only: the verdict, the exit code, the polling progress messages, and `lunar policy ok-pr` are unaffected. Printing the custom output also requires a CLI new enough to support it; an older CLI keeps the built-in output.
* A policy entry in `lunar-config.yml` can now carry arbitrary key-value annotations under `meta` — for example `enforce_control: CONTROL123` — and Lunar stores them and exposes them as a `meta` JSONB column on the SQL API `policies` view, so checks can be selected by an identifier of your own with a query such as `SELECT cl.* FROM checks_latest cl JOIN policies p ON cl.policy_id = p.id WHERE p.meta->>'enforce_control' = 'CONTROL123'`. Keys and values are free-form and are never shown to a person: they do not appear in pull-request comments or in the dashboards, a policy with no `meta` reads back as `{}`, and the annotations written on an entry apply to every sub-policy that entry imports, so mapping sub-policies to different values means importing the plugin once per sub-policy.
* The collector, policy, and cataloger dashboards now show a Definition column whose "view" link opens the exact place the script is declared — the `lunar-config.yml`, the `lunar-config.d/` fragment, or the imported plugin's own file — pinned to the commit that version of the Lunar configuration was read from and anchored at the declaring line. Links are built for github.com, GitHub Enterprise Server, and GitLab, including nested GitLab namespaces; a script whose definition has no browsable remote, such as one loaded from a local path, shows no link. Scripts stored before this release have no link until their Lunar configuration is read again.

### Improvements

* A component's check score now renders as a coloured gauge on the components listing, on the component details History tab, and on the Release Ledger, instead of coloured text, and a component with no checks shows a dash rather than a percentage. The History tab's Checks column is renamed Score, since the tab header already reports a different Checks figure, and the Release Ledger no longer repeats the Component column on a page already scoped to one component.
* Check and run status on the dashboards — component details, pull-request details, policy details, the Release Ledger, and the collector, run, and script-run listings — is now drawn with a status icon set instead of emoji, and each icon carries hover text saying what it means, such as "Failed - blocks the release" or "Failed, but reported only - does not block". A stale result, one not yet re-evaluated for the current commit, is now marked by a grey variant of its status icon, with a footnote beneath the table explaining it, replacing the `*` that used to be appended to the status.
* A gate bypass granted or withdrawn — through a `/lunar bypass` or `/lunar bypass rm` comment on a pull or merge request, or with `lunar policy bypass-release` and `lunar policy bypass-rm` — now refreshes the checks report in seconds. Lunar re-posts the results already stored for the commit instead of re-running every policy to recompute an answer a bypass cannot change, so the report, the GitLab project badge, and the component's score catch up with the ledger without waiting for a full evaluation. A commit that has never been evaluated still falls back to a full evaluation, and when an evaluation is already running for the commit, that evaluation's own post carries the bypass rather than a second one being started behind it. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* The Description column on the collector and policy details dashboards can now be inspected, so a description longer than the column width can be read in full instead of being cut off.
* The background materialization that keeps the SQL API tables up to date now holds a batch that has not filled for at most 5 seconds before running it, instead of up to 30 seconds, so check and component data materialized after a collection finishes becomes readable within a few seconds on a small or quiet installation. Batches that reach their size threshold still release immediately, so nothing changes for an installation whose updates arrive fast enough to fill them; the wait can be tuned with the `HUB_MAT_DRAIN_MAX_BATCH_WAIT` setting on the Hub when fewer, larger refreshes matter more than freshness.
* The Policies listing dashboard loads around 2.4× faster on large installations — 17.9 s to 7.4 s measured on an installation with roughly 30,000 components — because the query behind it no longer loses parallel execution while matching checks to policies.
* Policies at `score` enforcement now evaluate on pull requests, where they were previously dropped from dispatch regardless of their `runs_on` setting; `runs_on` — which defaults to `[prs, default-branch]` — is now the only thing deciding where a score policy runs. Their results appear on the pull-request and component dashboards and in the SQL API, and stay off the pull request itself: score checks are still excluded from the Lunar check run, the pull-request comment, and the merge-request note, cannot affect the merge gate, and do not contribute to the component's score from a pull-request run. This makes `score` the level at which to trial a guardrail — including one that only means anything in a pull request, such as a check on the pull request's title — before application teams see it. Score policies that declare `runs_on: [prs]` or the default now execute on every pull request, so policy run volume on pull requests increases accordingly.

### Bug fixes

* A break-glass command copied with its Markdown formatting is now understood: `/lunar bypass: <reason>` and `/lunar bypass rm` are recognized when the comment wraps them in backticks, as a code span or as a fenced code block. Previously such a comment did not start with a slash, so it was treated as ordinary conversation and the command was silently dropped — even though Lunar's own acknowledgement quotes the command that way. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* A customized bypass acknowledgement — `customization.bypass_template` or `customization.bypass_revocation_template` in the Lunar configuration — whose rendered reply would itself be read as a break-glass command is now replaced by the built-in default reply. Previously such a reply, for example one leading with a reason that spells out a bypass command, could be parsed as a new command when it arrived back as a comment, recording a bypass nobody asked for on the ledger that is the audit record for the control. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* A component that declares a `branch:` other than its repository's default branch is now collected and cataloged at the current commit on that branch. Scheduled `cron` collector runs, per-component catalogers with `clone-code: true`, and the re-evaluation triggered by granting or revoking a release-gate bypass previously took whichever branch of the repository was pushed to most recently, which is the right commit only when every component in the repository tracks the same branch. On a repository whose components track different branches, those runs used the wrong commit while still recording the result under the component's declared branch, so a cataloger checked out the wrong tree and a scheduled collector attributed another branch's data to the component.
* Rerunning code collectors now reaches components that track a branch other than the repository default. A rerun that covers every component built its clone reference from the repository's default branch, and any component declaring a different branch was then filtered out of the resulting collection, so the rerun silently collected nothing for it; it now issues one run per branch the repository's components track, each at that branch's current commit. Naming such a component explicitly with `lunar collector run` previously resolved its code collectors against the default branch while its cron collectors ran on the branch the component tracks, so a single command collected the component's data at two different commits; both now resolve the same commit. A component whose declared branch is a glob pattern has no single ref to run against: `lunar collector run` now fails with an error naming the component and the branch, before dispatching any collector, instead of collecting the default branch.
* Data collected against a commit that already has collected data now reaches Component JSON as served by the SQL API `components` and `components_latest` views and the dashboards built on them. Previously any write that appended to an existing commit's data — re-running `lunar collector run` against that commit, an out-of-band `lunar collect --sha` submitting release metadata against a commit built earlier, or a scheduled collector writing to an older commit — refreshed the checks derived from that data within minutes but left Component JSON serving the pre-append value indefinitely, because these views discovered data by when it was first collected for the commit rather than when it was last written. A commit already left stale becomes correct again the next time data is collected for it.
* A configuration pull that reruns global catalogers — `lunar hub pull --rerun-catalogers`, or `rerun-catalogers: "true"` in the config-sync job — no longer hangs when the Hub runs more than one replica. The pull now polls for each cataloger run's completion instead of listening only for runs finished by the replica serving the request, so a run picked up by a sibling replica is noticed and the command returns as soon as the catalogers are done. Previously such a pull kept its stream open until a ten-hour timeout, blocking the CI runner that issued it and every later pull queued behind it.
* The domain scorecards on the Home, Domains, and Domain details dashboards now center the medal in their Badge column, aligning it with the Score and Checks columns beside it, and give the Checks column enough room for large counts, so a ratio such as 4K/836.4K is shown in full instead of truncated.
* A GitLab API call that connects but never answers is now cut off after 60 seconds instead of hanging until the webhook delivery's own two-minute budget runs out. Previously one unresponsive call consumed the entire budget for a delivery and every remaining step was skipped — a `/lunar bypass:` command, for example, could record the bypass but never post its confirmation reply or trigger the policy re-evaluation, leaving the commenter unanswered until the delivery was retried.
* Collectors with a `cron` hook that declare `runs_on: [prs]` now run against open merge requests on GitLab-hosted components. The query listing a repository's open pull requests matched only GitHub's spelling of the open state, so it returned nothing for GitLab projects and scheduled collectors never ran in merge-request context there. Volume stays bounded by the existing limit of 50 open pull requests per component per scheduled run, but a GitLab installation should expect scheduled collection to step up once this work starts happening.
* A component that pins a branch other than its repository's default — `branch:` in the Lunar configuration — now resolves its current state from the head of that branch. Lunar tracks the current commit per component rather than one commit per repository, so the SQL API `checks_latest` and `components_latest` views, the dashboards built on them, and a component JSON lookup with no commit given (`lunar component get-json` without `--git-sha`) all report the pinned branch's newest commit; previously they resolved against the repository's default branch, so a component pinned elsewhere showed the state of a branch it does not track — or nothing at all once its default branch stopped advancing. A repository whose components track different branches now has a distinct current commit for each of them, and the pointer is kept current by pushes and repaired by the periodic repository sync if a webhook delivery is missed. Components that track their repository's default branch are unaffected.
* Collector and policy runs belonging to a merged GitHub pull request stay visible on the runs dashboards instead of disappearing the moment the pull request merges, and a merged pull request stays selectable in the dashboards' pull-request filters and listed on the component details dashboard. GitHub reports a merge through a field separate from a pull request's state, so Lunar stored a merged pull request as closed, indistinguishable from an abandoned one, and the dashboards — which hide runs from closed pull requests — dropped the guardrail history of exactly the changes that shipped. Merged and closed-without-merging are now recorded distinctly for both GitHub and GitLab. Pull requests merged on GitHub before this release stay hidden, because the data already stored cannot separate them from abandoned ones; GitLab merge requests were never affected.
* An out-of-band `lunar collect` now merges into a component's data at that commit in every case, instead of occasionally replacing it. When a Lunar configuration change had given the component a new identity and no collector had yet run under it, the out-of-band write was read as a fresh collection: the data collected under the previous version stopped being carried forward, leaving the component with only the newly written key and every check reporting no data. Data collected by CI, code, scheduled, and unknown-source collectors still ends the carry-forward as before. See [Out-of-band collection](#product-out-of-band-collection).
* The Hub's periodic repository sync now covers every branch the repository's components track — the `branch:` each component declares in the Lunar configuration — alongside the repository's default branch on the forge. Previously the sync walked the default branch only, so a component pinned to another branch never had that branch's commits ingested and open pull requests targeting it were dropped, leaving gaps whenever a webhook delivery was missed or a repository was newly tracked. A branch pattern such as `release-*` contributes no ref, a repository's sync covers at most eight branches with the rest named in a warning, and a tracked branch that no longer exists on the forge is skipped with a warning instead of failing the whole sync pass.
* A GitHub Actions workflow run that GitHub reports against a pull-request ref such as `refs/pull/41/head`, without associating it with the pull request itself, is no longer discarded. Lunar now recognizes the ref, matches components against the repository's default branch, records the run, links it to the pull request it came from, and starts the pull request's policy evaluation when CI completes. Previously such a run matched no component under any configuration, so nothing about it was recorded and policy evaluation was never triggered by its completion. A pull request whose base is not the default branch is unaffected: GitHub associates those runs with the pull request directly, and that path already knows the real base.

## 2026-08-19 <a href="#product-2026-08-19" id="product-2026-08-19"></a>

### Breaking changes

* A Lunar configuration declaring two components whose names differ only by the letter case of their repository identity now fails the configuration sync with an error naming both components. Because repository identity is compared case-insensitively, such a pair is one repository addressed two ways, with no defensible answer for which spelling to display — use one spelling. Components that differ only in the case of their monorepo subdirectory path are still distinct and remain allowed. See [Case-insensitive repository identity matching](#product-2026-08-19).

### Bug fixes

* A component's checks can no longer be left pending indefinitely when the component declares `after-json` or `missing-json` collectors but none of their hooks match the commit's Component JSON — for example an `after-json` hook on `.vcs.pr.title`, which is present on a merge request but absent on every branch push. Previously the evaluation waited forever for a collector wave that would never fire, so every check on the component stayed pending and `lunar policy ok-release` hung waiting on those policies; the evaluation now finalizes as soon as it is known that no hook will fire. See [Collector data dependencies: the `after-json` hook](#product-after-json-collector-hook).
* Pushes and merge-request events now reach a component whose declared repository identity differs in letter case from the identity the forge reports: the host, owner, and repository name — including GitLab namespaces and projects — are compared case-insensitively, matching how GitHub and GitLab themselves resolve them. Previously a GitLab component declared with a group's display-name casing (for example `GitOps/project`, where the group's URL path is `gitops`) silently received no webhook processing at all, so its collections and checks never ran. The spelling the author typed is still what Lunar stores and displays, and a monorepo component's subdirectory path remains case-sensitive, since it is a file path.

## 2026-08-18 <a href="#product-2026-08-18" id="product-2026-08-18"></a>

### Features

* The reply Lunar posts after a `/lunar bypass rm` revocation can be customized independently of the grant reply: set `customization.bypass_revocation_template` in the Lunar configuration to the path of a Go `text/template` file in the configuration repository. It accepts the same parameters and passes the same validation as `customization.bypass_template` — a missing file, invalid template, or unsupported parameter fails the configuration pull before anything is published — and the built-in revocation reply is used when the setting is unset. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* A blocked GitHub pull request can now be unblocked from its own comment thread, at parity with GitLab: an engineer whose repository role is `maintain` or `admin` comments `/lunar bypass: <reason>`, and Lunar records the bypass, marks the Lunar check on the pull request as passed for its current head commit, and confirms with a reply. The bypass is bound to that commit — a new push re-blocks — and is recorded on the same ledger as CLI-created bypasses, so it shows up in `lunar policy bypass-ls`, is queryable through the SQL API `bypasses` and `bypassed_checks` views, and leaves an audit trail of the checks it let through when the pull request merges. Commenting `/lunar bypass rm` withdraws the comment-driven bypasses on the pull request (CLI-created bypasses are left to `lunar policy bypass-rm`), immediately re-arms the check, and queues a re-evaluation so the settled verdict arrives without a new push. Replies honor the `customization.bypass_template` and `customization.bypass_revocation_template` settings. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* A GitLab merge-gate break-glass bypass can now be withdrawn from the merge request it was granted on: a user with the Maintainer role or above comments `/lunar bypass rm`, optionally adding a reason as `/lunar bypass rm: <reason>`. Lunar revokes the bypasses created through comments on that merge request, sets the Lunar status check back to pending, queues a policy re-evaluation so the settled verdict arrives on its own, and replies in the command's thread confirming the revocation. Bypasses created with `lunar policy bypass-pr` are left intact, and revoked bypasses remain on the ledger for auditing — marked with who revoked them and when — while dropping out of `lunar policy bypass-ls --active`. The reply posted after a grant now also mentions the new command. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* The Lunar results comment on a pull or merge request can now stay quiet until the first failure: set `customization.pr_comments.mode: only-failures` in the Lunar configuration. While every displayed check passes and Lunar has not yet commented on the request, no comment is posted; the first failing, erroring, or still-pending result posts the comment, and from then on it keeps live-updating as usual, including back to all-green once the failure is fixed. The trade-off is that an all-green request has no live view of collectors that have not reported yet. The default, `mode: always`, keeps the existing behavior, and commit statuses and the merge gate are unaffected either way.

### Improvements

* The Blocking checks column on the component dashboard's pull-requests list now counts failures against only the checks that can actually block the merge — those at `block-pr` or `block-pr-and-release` enforcement — instead of every check, so a merge request one failure from green reads `2/15` rather than `2/74`, and the pull-request details page shows the same fraction where it previously showed a bare count. The denominator describes the configuration, so it does not shrink when a bypass temporarily clears one of the checks.
* The checks report posted with check results — the GitHub Check Run text, the GitHub pull-request comment, and the GitLab merge-request note — now points a blocked author at the break-glass: when the report lists failing required checks, that section includes a hint to comment `/lunar bypass: <reason>` to override the merge gate. Advisory, pending, passing, and errored sections are unchanged, and a custom `report_template` is unaffected. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* The SQL API `checks` and `checks_latest` views — and the background process that keeps their materialized data fresh — are now around 3× faster for components that have accumulated many Lunar configuration versions (measured 4.3 s to 1.4 s for one such component on a large installation). Query results are unchanged.
* Refreshing the SQL API `checks` data is significantly faster on large installations: each incremental refresh previously scanned the entire checks projection to remove the rows it was about to rewrite — about 4.4 seconds and several gigabytes of I/O to delete roughly a thousand rows — and now removes only the affected rows, making the cleanup step around 15× faster on an installation with about 13 million check rows. New check results reach the `checks` and `checks_latest` views — and the dashboards built on them — sooner, especially during bursts of activity.
* SQL API tables now materialize concurrently: incremental refreshes that touch different repositories run in parallel instead of queuing behind one another cluster-wide, roughly tripling drain throughput, so the materialized data behind the SQL API views and the dashboards reflects recent activity sooner during bursts of commits and collections on large installations. Refreshes that touch the same repository still run one at a time, so results are unchanged — only fresher.
* The component and pull-request dashboards now surface active gate bypasses instead of showing a waived check as work still to do. A failing blocking check that an active bypass clears shows 🔓 rather than ❌, the header banner counts it separately — "42 checks are passing, 31 non-blocking checks are failing, 1 bypassed" — and the Blocking checks count and Ready-to-merge tick exclude waived checks, so the summary no longer contradicts the banner. A new Bypasses tab on each page lists the overrides standing against that gate — scope, policy, actor, verified role, source, reason, and remaining time — and the component page links into the Bypass Audit trail for the component's full bypass history across both gates. On a pull request, a component-wide bypass pinned to a different commit is not listed, since it masks nothing there. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* While the merge-gate banner on the pull-request dashboard or the release-gate banner on the component dashboard blocks, a hint line now appears beneath it telling the reader how to get past the block. By default the hint quotes the exact bypass invocation for that component and change request — the `/lunar bypass: <reason>` comment or `lunar policy bypass-pr` for the merge gate, `lunar policy bypass-release` for the release gate. Set `customization.bypass_hint.pr` and `customization.bypass_hint.release` in `lunar-config.yml` — one paragraph of inline Markdown each — to point readers at your own process instead, such as a Slack channel or runbook; an empty, multi-paragraph, or over-4-KiB hint fails the configuration sync with an error naming the field. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* The `/lunar bypass: <reason>` comment break-glass now works on GitLab tiers below Ultimate, where the Lunar commit status — what the "Pipelines must succeed" setting reads — is the merge gate rather than an Ultimate-only external status check. A granted bypass is recorded on the ledger whatever the tier, sets the commit status to success for the merge request's head commit immediately, and holds it green for that commit even while an evaluation is still pending or after a later re-evaluation; `/lunar bypass rm` returns the status to running. Previously such a command replied that there was nothing to bypass and recorded nothing. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* The component details dashboard's Deployment history tab is renamed History; its content is unchanged, but a saved bookmark of the old tab URL will land on the default tab.
* The background refresh that keeps the runs dashboard current now finds recently active components through an index-backed lookup instead of scanning the entire script-run history. On large installations that scan averaged over a minute per refresh cycle — most of the refresh's time budget spent before any listing rows were updated — which could leave the runs dashboard lagging well behind recent activity.
* Scoped materialization refreshes behind the SQL API `checks` and `components` views no longer rescan the entire commit table on every refresh, so their cost no longer grows with the installation's total commit history. On a production-scale installation under load, a scoped checks refresh is about 2.4× faster (1.05 s to 430 ms), so materialized check and component data keeps up better during bursts of commits.
* The background materialization that keeps the SQL API tables up to date now processes pending updates in larger batches: the cost of a refresh is dominated by a fixed scan, so a batch of 100 keys costs little more than one of 25, and a nearly empty batch is held for up to 30 seconds to let it fill before running. Materialized data keeps up better during sustained bursts of activity, at the cost of at most about 30 seconds of added latency on an otherwise quiet installation.
* The checks report posted with check results — the GitHub Check Run text, the GitHub pull-request comment, and the GitLab merge-request note — now names each check by the policy it came from, so two policies that declare the same check name render as two distinguishable lines (`container-scan.max-severity` and `iac-scan.max-severity`) instead of two identical rows. When the policy name already ends with the check name it is not repeated, and every rendered name is a selector `lunar policy bypass-pr --policy` accepts. Only the report body changes; merge gating, check conclusions, and recorded bypasses are unaffected.
* The background refresh that keeps the runs listing dashboard current no longer reads a component's entire run history to recompute its rows: it now reads only the rows on each repository's current head commits, so the work is proportional to what the listing shows rather than to accumulated history. On a production-scale installation this made a cold refresh of 100 components around 11× faster (16.7 s to 1.5 s), and refreshing the components with the largest histories dropped from 117 s to 41 s, so the listing stays current under load instead of falling behind. The listing's contents are unchanged.

### Bug fixes

* A `/lunar bypass` or `/lunar bypass rm` comment command that fails now posts a reply in the pull-request or merge-request thread saying the command did not happen and why — a GitHub rate limit, for example, names when its budget resets — instead of failing silently, and the webhook delivery is reported as failed so redelivering it retries the command safely. A custom reply template set with `customization.bypass_template` that predates the new `.Error` parameter is answered from the built-in template for that one reply. The exception is a command that fails after the bypass has already been recorded: the thread is left unanswered rather than told nothing happened, and redelivering the webhook completes the command without creating a duplicate. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* Granting a gate bypass through a pull-request or merge-request comment now triggers a policy re-evaluation for the affected components, so the Lunar results comment is refreshed to reflect the override. Previously only the check or status turned green while the comment went on listing the bypassed checks as failing until the next push. Applies on GitHub and GitLab alike. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* Component JSON no longer nondeterministically omits data that was collected and stored. After a Lunar configuration update, a component could accumulate several stored data snapshots for the same commit that tied on the ordering used to pick which one to serve, and the tie was resolved by physical database row order — so the same collected data could be visible on one deployment and absent on another, and could flip on any unrelated row update. The freshest snapshot, which contains all of the collected data, is now always the one served, in both the SQL API `components` and `components_latest` views and the stored Component JSON that collectors and policies read.
* A collector's scheduled `cron` run that calls `lunar collect` more than once now lands every write in the component's Component JSON. Previously only one arbitrary record from each cron run survived into the merged data — the rest were silently dropped, and which one survived could change between runs — so a cron collector writing several paths (for example an on-call collector writing the schedule, escalation, and summary) appeared to have collected almost nothing, and policies reading the missing paths could report false failures. Code and CI collector runs were never affected. The dropped records were stored all along, so each affected component repairs itself on its next scheduled run with no action needed. Within a single run, writes now also merge in the order the collector emitted them, so a key written twice in one run deterministically keeps the later value.
* Repository syncing no longer fails repeatedly for a repository that has been deleted on GitHub — or that the Lunar GitHub App can no longer access — while a component still references it. Because GitHub reports both states identically, the Hub now skips installing the repository's webhook with a warning instead of erroring on every sync attempt, defers the repository's next sync by one reconcile window instead of retrying the same failing sync on every periodic sweep, and probes it again afterwards — leaving the component's configuration intact, so a repository that is restored, or whose App access is restored, resumes syncing automatically with no intervention.
* The Lunar check posted on a GitHub pull request now honors active bypasses: a pull request whose failing blocking checks are all covered — by `lunar policy bypass-pr` or by the `/lunar bypass:` comment — turns green instead of staying red, while a partial bypass continues to block, and the bypass survives later re-evaluations of the same commit instead of being overwritten by the next posted result. The Lunar comment on the pull request now lists the bypassed checks with who authorized the override, instead of showing them as failing under a passing check. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* A GitHub webhook event whose processing fails — a push, pull-request, workflow-run, or bypass-command delivery — is now answered with an error status, so GitHub records the delivery as failed and it can be redelivered from the App's delivery log. Previously the Hub acknowledged every delivery as successful even when handling failed, so dropped events were invisible and could never be redelivered; on installations that hit rate limits, the delivery log will now show failures that were always happening but previously reported as successes.
* A GitLab merge request can no longer be left blocked indefinitely when the components it matched disappear before its code collection runs — for example when a Lunar configuration change removes them while the collection job is queued. The mandatory Lunar status check on the merge request previously went unanswered in that window; it is now answered as passed, the same "Lunar has no opinion" verdict a merge request that matches no component receives. GitHub pull requests are unaffected — nothing is posted for them in this case.
* A GitLab merge request on a tracked component that no policy targets is no longer blocked indefinitely: the "Earthly Lunar" external status check now answers with passed when an evaluation dispatches no policies. Previously the check was never answered on such components, and because Lunar enables "Status checks must succeed" on gated projects, the merge request could not be merged and there was no recovery.
* Processing triggered by a GitLab webhook — push, merge-request, and comment events — now runs to completion even after GitLab closes the connection at its ten-second delivery limit, matching the existing behavior for GitHub webhooks. Because GitLab does not retry a timed-out delivery, the disconnect previously cancelled whatever the Hub had left to do; for example, a comment-driven merge-gate bypass could be recorded and take effect without its acknowledgement reply ever being posted on the merge request.
* A GitLab repository webhook left pointing at a previous deployment's URL is now repaired automatically: when the Hub's public URL changes, its periodic webhook verification deletes the stale hook and recreates it at the current URL, matching what it already did on GitHub. Previously the Hub adopted such a hook as-is, so GitLab kept delivering pushes to the dead endpoint and no code collection ran for the repository.
* The Global catalog section of the Catalogers dashboard — the Tree and JSON views, the version-picker strip, and the Diff tab — now reflects the current catalog. Since scoped catalog materialization became the default, these panels silently served a snapshot frozen at the cutover, so their domain and component counts fell ever further behind the Catalogers table above them, and the version strip offered no newer date to pick. With no version selected the Tree now shows the live catalog, and the strip and Diff read the captured catalog version history. The rendered document can differ slightly from before: it no longer carries unmodelled keys or domain metadata that the old raw merged snapshot included.
* The Manifest panel on the home dashboard now links to the `lunar-config.yml` the Lunar configuration was read from for configurations hosted on GitLab — gitlab.com or self-managed — and on GitHub Enterprise Server. Previously the link was always built on `https://github.com/`, so for any other host it pointed at a nonexistent GitHub path and returned 404.
* The Repository column on the Home dashboard's Manifest panel now sizes itself instead of being pinned at 350px, so a long Lunar configuration repository URI — for example one on a self-managed GitLab host — is shown in full instead of truncating mid-path; the fixed width now applies to the version column, which holds a fixed-length commit SHA.
* The pull-request details dashboard no longer times out loading its check scorecard on large installations: the database lookup that resolves each policy's latest run against a pull request is now indexed, and the scorecard queries on the pull-request, component details, and Release Ledger dashboards no longer re-run that lookup once per check row. Measured on a production-scale installation, the pull-request scorecard query dropped from about 55 seconds to 31 milliseconds.
* The runs listing dashboard's incremental refresh no longer times out on large installations. Clearing a single component's rows before recomputing them previously scanned the entire listing table, so under load the refresh could back up by hours and the dashboard showed stale run results; the rows are now located directly, keeping run changes visible within seconds as they arrive.
* The runs listing dashboard no longer falls behind for every component when a few components have very large run histories. Previously, a component whose runs-listing refresh could not complete within its time budget was retried every minute without ever finishing, consuming refresh capacity and delaying updates for all other components; such a component now backs off with increasing delays instead, so everyone else's runs keep appearing promptly.
* Dashboards now decide whether a component is on GitLab from its actual repository host — matched against the GitLab hosts the Hub is configured with, plus gitlab.com — instead of guessing from a `gitlab` name prefix that only ever matched gitlab.com. Components on a self-managed GitLab host previously rendered as GitHub everywhere: tabs read "Active PRs" instead of "Active MRs", change requests showed `#` instead of `!`, the repository link was labelled `github`, and the repository and merge-request links were built in GitHub's URL shape and returned 404. Those components now render with GitLab's noun, sigil, and link shapes, including nested subgroups and monorepo sub-components. Rendering for gitlab.com, GitHub, and GitHub Enterprise Server components is unchanged.
* The Hub now cleans up its leftover stale webhooks on GitHub repositories and GitLab projects even when a healthy webhook is already installed; previously a leftover Lunar webhook pointing at an outdated Hub URL could linger on the repository indefinitely, with the SCM delivering its events nowhere. Removing Lunar's webhook from a repository — for example when the last component referencing it leaves the catalog — now deletes every Lunar-owned webhook rather than only the first, and webhook reconciliation on GitHub now finds Lunar's webhook even on repositories with more than 30 webhooks configured.
* The checks report posted with check results — the GitHub Check Run text, the GitHub pull-request comment, and the GitLab merge-request note — now spells a check's policy-qualified name the same way in every section: a bypassed plugin check is named exactly as it is in the failing sections, instead of rendering with its policy name doubled (for example `container-scan.max-severity.max-severity` under Bypassed while Failing showed `container-scan.max-severity`). A check that failed before it could report a name — for example when its policy's container image cannot be pulled — is now listed under its policy's name instead of an indistinguishable `Execution failure` row per policy; only a check with no policy still reads `Execution failure`. Within each status section, checks are now ordered by the name the report displays rather than an internal bare check name, so same-named checks from different policies sort deterministically.
* A cataloger's results can no longer be silently lost when two runs of the same cataloger write to the catalog at the same time — for example when pushes to different commits trigger concurrent runs, or when a globally scoped cataloger runs on more than one Hub replica at once. Previously such writes could collide, and the losing run's entire result was discarded without an error, leaving the catalog on the older data until the cataloger happened to run again; concurrent writes for the same cataloger are now serialized so every result is recorded.
* The Lunar release badge on a GitLab project now honors release-gate bypasses: when every failing release-gating check is covered by a `lunar policy bypass-release`, the badge reads "release bypassed" in yellow — naming the override rather than showing a plain green pass — while a partial bypass keeps it at "release blocked". Granting or revoking a release-gate bypass also triggers a fresh evaluation of the component's default-branch head, so the badge updates without waiting for the next push; previously a bypass cleared `lunar policy ok-release` while the project badge kept reading "release blocked" indefinitely. The Lunar commit status on the default branch deliberately keeps the un-bypassed verdict, so the two read together as blocked, and overridden. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* A check's multi-line assertion failure messages in the checks report posted with check results — the GitHub Check Run text, the GitHub pull-request comment, and the GitLab merge-request note — now render as a nested list, one item per line, instead of being collapsed into a single run-on line. A policy that emits one finding per line, such as a CVE list, reads as a list again; blank lines are dropped and a line's own leading list marker is replaced by the report's nested bullet. The injection safeguard is unchanged: user-influenced text still never opens a line of the report, so it cannot invoke a GitLab quick action.
* The background refresh that keeps the runs listing dashboard current no longer loses throughput after an occasional long refresh pass. Previously, a pass whose overall time budget expired mid-refresh also halved how much work later passes would take on — a penalty recovered only gradually — so a few slow passes an hour could quietly ratchet the refresh down and leave recent run activity waiting to appear. The pass budget is now sized so a slow individual refresh is deferred and retried on its own instead of ending the whole pass.

## 2026-08-13 <a href="#product-2026-08-13" id="product-2026-08-13"></a>

### Features

#### Customizable checks report templates <a href="#product-checks-report-templates" id="product-checks-report-templates"></a>

The Markdown report Lunar posts with check results can now be customized. Set `customization.checks_template` in the Lunar configuration to the path of a Go `text/template` file in the configuration repository, and that one template controls all three report bodies: the GitHub Check Run text, the GitHub pull-request comment, and the GitLab merge-request note.

Lunar validates the template when it pulls the configuration — a missing file, an invalid template, or an unsupported field fails the pull before a new configuration version is published — and stores the validated template with that version, so every report renders with the template of the configuration it was produced under. A template controls presentation only: check names, GitHub conclusions, GitLab states, required-check calculation, bypass decisions, and gate behavior are unaffected. If a stored custom template unexpectedly fails at render time, Lunar logs the error and posts the built-in report instead.

The template receives the checks grouped into status sections, any bypassed checks, and report context such as the component, commit, pull-request number, and dashboard URL. The full template contract — and the exact built-in template to start from — is documented in the Lunar configuration reference under `customization.checks_template`.

### Improvements

* The built-in checks report posted with check results — the GitHub Check Run text, the GitHub pull-request comment, and the GitLab merge-request note — is reorganized: checks are grouped into collapsible status sections, failing required checks are listed separately from failing non-required ones, a non-required failure is marked ⚠️ rather than ❌, check names render as inline code, and a single More Details link closes the report. See [Customizable checks report templates](#product-checks-report-templates).
* The component details header now shows a release-gate banner, mirroring the pull-request header's merge banner: when any check at `block-release` or `block-pr-and-release` enforcement is not passing on the component's default branch, it reads "N checks are required to release" in red; otherwise it summarizes in green how many checks pass and how many non-blocking checks fail.
* The component details dashboard's Release history tab is renamed Deployment history; its content is unchanged.
* The check scorecards on the component details, pull-request, and Release Ledger dashboards now share one status-icon set: a failing check that is not blocking shows ⚠️ instead of ❌ so it reads as a warning rather than a hard block, a pending check shows ⏱️, and an errored check shows ❗. The redundant "required" column is removed in favor of the enforcement-level column, which now renders color-coded by severity. The Release Ledger scorecard links each check name to its policy details and renames its Why column to Failure reason with the full text openable in the cell inspector, and the pull-request scorecard gains a Run ID column linking to the run's details, matching the component details dashboard.

### Bug fixes

* The SQL API `checks` and `checks_latest` views — and the dashboards built on them — no longer return a leftover blank row beside a component's real checks, which made a component with five checks report six rows and skewed every count over the SQL API. The derivation that produced these placeholder rows was fixed in the previous release; upgrading to this one also deletes the rows already written. A commit that was seen but had nothing checked keeps its single placeholder row, so such commits remain visible.
* A `runs_on` value set where a plugin is imported via `uses:` now applies to imported collectors and policies that declare no `runs_on` of their own. Previously the import-site value was silently dropped and the imported scripts kept the global default `[prs, default-branch]`, so a collector restricted to `runs_on: [prs]` still ran on the default branch. An import that leaves `runs_on` unset is unchanged: the imported script keeps its own default, so a cron-only collector still runs only on the default branch.

## 2026-08-12 <a href="#product-2026-08-12" id="product-2026-08-12"></a>

### Features

* The SQL API gains a `catalog` view: the catalog over time, as a series of full snapshots, complementing the existing `catalog_latest` view. Each row is a complete catalog JSON document with a `timestamp` column saying when the catalog last changed and a `captured_at` column saying when the version was recorded; the newest row is always the current catalog, matching `catalog_latest`. Order the history by `captured_at` — `timestamp` can repeat across rows. The history is sampled rather than exhaustive: versions are recorded periodically, so several changes landing close together may appear as one row. History begins once the Hub starts recording versions; for anything earlier, `lunar cataloger get-json --ts` reconstructs any past catalog from the cataloger delta history. Recorded versions are retained for 90 days by default, and the newest version is always kept.

### Improvements

* The Initiatives dashboard loads around 3× faster on large installations — 5.5 s to 1.7 s measured on an installation with roughly 30,000 components — because its backing database functions no longer force the queries that call them onto a single core.

## 2026-08-11 <a href="#product-2026-08-11" id="product-2026-08-11"></a>

### Breaking changes

* A collector's `after-json` hook now fires only when its declared Component JSON `path` ended up present once the component's collection settled; previously it fired whether or not the path was populated. A collector that relied on `after-json` to run as a fallback when the path is absent must declare the new `missing-json` hook instead, and a collector that needs both cases declares both hooks on the same path. See [Collector data dependencies: the `after-json` hook](#product-after-json-collector-hook).
* A GitLab merge-gate break-glass bypass must now be invoked as a slash command: comment `/lunar bypass: <reason>` on the blocked merge request. The previous bare `lunar bypass: <reason>` phrase no longer triggers a bypass, so ordinary discussion cannot accidentally invoke the break-glass path. Acknowledgements and rejections are now posted as replies to the triggering comment instead of as separate notes. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).

### Features

* The reply Lunar posts after a comment-driven gate bypass can now be customized: set `customization.bypass_template` in the Lunar configuration to the path of a Go `text/template` file in the configuration repository. Lunar validates the template when it pulls the configuration — a missing file, invalid template, or unsupported parameter fails the pull before anything is published — and uses its built-in acknowledgement when the setting is unset. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* The SQL API gains a `catalog_latest` view holding the current catalog — the full inventory of domains and components — as a single JSON document. It returns at most one row, and the document matches what `lunar cataloger get-json` returns, so a `lunar sql` query and the CLI always agree.
* Collectors can now declare a `missing-json` hook, the complement of `after-json`: it fires once a component's collection has settled for a commit, only when the declared Component JSON `path` is absent, making fallback collection explicit — for example generating an SBOM only when no other collector produced one. A path whose value is `null` counts as present, matching the Policy SDK's distinction between null and missing. See [Collector data dependencies: the `after-json` hook](#product-after-json-collector-hook).

### Improvements

* The documentation now has a Guides section, opening with a Cataloging Strategy guide: a three-phase, eight-step approach to cataloging for teams beginning a Lunar rollout.
* A new guide documents how to reach a Lunar Dedicated hub over inbound AWS PrivateLink. The existing outbound PrivateLink material is split into its own page, and the setup walkthrough now covers both directions.
* The SQL API `bypassed_checks` view now records a masking only when the bypass's verdict is actually consumed — when the pull request merges for the PR gate, or when `lunar policy ok-release` passes for the release gate — rather than at every gate re-evaluation, so bypass audit data reflects what actually shipped instead of how often the gate re-ran. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* The Collectors listing dashboard now serves its run counts from pre-computed data instead of recomputing them from raw run rows on every page load, making the page around 10× faster on large installations (13.6 s to 1.28 s measured on a production-scale tenant).
* Lunar Hub now uses conditional requests (ETag caching) for its GitHub API calls by default, reducing GitHub API rate-limit consumption. Every request is still revalidated with GitHub — a cached response is used only when GitHub confirms it is unchanged — so results are never stale.
* Catalog materialization is now scoped to what changed, by default on all installs: a catalog change updates only the affected components instead of rebuilding the whole catalog on every change.

### Bug fixes

* When a commit belongs to more than one pull request, the SQL API `checks` and `checks_latest` views — and the dashboards built on them — now show the commit's checks under each of its pull requests, deterministically. Previously which pull request a check row carried was decided by the database's query plan, so a plan change, a statistics update, or a Postgres upgrade could silently reassign the pull request shown on up to half of a component's checks, and the commit's other pull requests could show no checks at all.
* The periodic cleanup that resolves policy and collector runs stranded in a non-terminal state now sweeps in bounded batches, so it always makes progress and completes within its budget. On large installations the sweep previously timed out on every tick, leaving stuck runs unresolved indefinitely.

## 2026-08-07 <a href="#product-2026-08-07" id="product-2026-08-07"></a>

### Breaking changes

* The SQL API `bypassed_checks` view is now an append-only event log: every check masked at a gate evaluation is recorded as a separate immutable row with a `masked_at` timestamp, so the full masking history — how many times, and exactly when — is preserved for audit. The `first_masked_at`, `last_masked_at`, and `mask_count` columns are removed; queries that read them should aggregate over the event rows instead, for example `COUNT(*)` and `MIN`/`MAX` of `masked_at` grouped by `bypass_id`, `sha`, and `check_name`. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).

### Improvements

* A GitLab merge-gate bypass created through a merge-request comment is now recorded on the same ledger as bypasses created from the CLI: it shows up in `lunar policy bypass-ls`, can be revoked early with `lunar policy bypass-rm`, is queryable through the SQL API `bypasses` and `bypassed_checks` views, and behaves identically to a CLI-created bypass. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).

### Bug fixes

* The Active PRs panel on the component details dashboard no longer lists pull requests that touch none of the component's paths; in a monorepo, unrelated pull requests previously appeared as empty `0/0` rows. See [Monorepo support: path-scoped components](#product-monorepo-path-scoped-components).
* The Lunar status check posted on a GitLab merge request now honors bypasses created from the CLI: a merge request whose blocking checks are fully bypassed with `lunar policy bypass-pr` turns green instead of staying red, while a partial bypass continues to block. See [Break-glass gate bypasses](#product-break-glass-gate-bypasses).
* Merge-request numbers on GitLab-hosted components now display with GitLab's `!` sigil instead of `#` on the pull-request details and component dashboards.
* The green release-gating project badge on GitLab now reads "release ready" instead of "released", since Lunar observes the release gate rather than the deployment itself; projects with no release policies configured no longer show "released" incorrectly.
* A pull request's head-commit pointer now advances correctly when two commits share the same one-second-resolution timestamp; previously such a tie could leave the pointer on the wrong commit, making the pull request's checks disappear from the component dashboard. Commit ordering during GitLab repository syncing now follows the same rule.

## 2026-08-04 <a href="#product-2026-08-04" id="product-2026-08-04"></a>

### Features

#### Break-glass gate bypasses <a href="#product-break-glass-gate-bypasses" id="product-break-glass-gate-bypasses"></a>

When a blocking policy fails but a change has to ship anyway, a bypass overrides a component's release gate or PR/MR merge gate for a bounded window, instead of the policy being disabled for everyone. Every bypass expires — omitting a duration uses the configured `bypass.max_duration`, never infinity — and a duration longer than that cap is rejected rather than silently shortened. A bypassed gate never goes silently green: it still names every check it suppressed, who authorized the override, and until when, and checks the bypass does not cover keep blocking. A bypass covers one component and one gate, optionally narrowed to a commit, a pull request, or a single policy or check; a component-wide bypass is rejected unless it is deliberately narrowed or given an explicit duration. Revoking a bypass early is an audited soft-revoke — the record is retained — and bypasses, along with the individual checks they suppressed, are queryable through the SQL API `bypasses` and `bypassed_checks` views. Bypasses are created, listed, and revoked with the `lunar policy bypass-release`, `bypass-pr`, `bypass-ls`, and `bypass-rm` commands.

## 2026-07-30 <a href="#product-2026-07-30" id="product-2026-07-30"></a>

### Features

* `lunar-config.yml` accepts a new `image_replace` list that rewrites image references after they have been resolved, so every image the configuration produces — including images set by an imported plugin's `default_image`, which a consumer configuration could not previously override — can be redirected to an approved registry or internal mirror. Rules are evaluated in order and the first match wins, so two rules sharing a prefix cannot compound into a doubled registry path; `from_pattern` is an unanchored Go regular expression (anchor a registry prefix with `^`), and `to` may reference capture groups as `$1`, `$2`, and so on. The `native` value is never rewritten, since it names an execution mode rather than an image, and Lunar logs a warning for any rule that matched no images during a pull.

## 2026-07-28 <a href="#product-2026-07-28" id="product-2026-07-28"></a>

### Features

#### Release Ledger dashboard <a href="#product-release-ledger-dashboard" id="product-release-ledger-dashboard"></a>

Dashboards now include a Release Ledger — a per-release view of a component. For a chosen release commit it shows the release's check score and details, the initiatives that apply, and compliance evidence drawn from the release data collected into the component's `.vcs` arrays (pull requests and tickets).

The Release Notes tab lists the pull requests and tickets that went into the release as collapsible cards: the title stays visible, the full description renders as Markdown on expand, and each pull request number and ticket id links to its source. A From selector turns the tab into a range view — pick an earlier release as the baseline (it defaults to the previous scored release) and the tab aggregates the pull requests and tickets of every release in between, de-duplicated.

Each release listed in a component's Release history links to its Release Ledger.

* The component details dashboard gains a paginated Release history tab listing the component's releases — default-branch commits with at least one scored check — with each release's check pass rate, build timestamp, and the pull requests and tickets it carried. A release with nothing scored shows no value rather than 0%, and each row links to that release's Release Ledger. See [Release Ledger dashboard](#product-release-ledger-dashboard).

### Improvements

* A collector that fails terminally no longer fails the entire collection cycle for that commit. Previously the whole dispatch was retried, re-running every collector and re-evaluating every policy several times; the failed collector is now isolated, other collectors' results stand, and policies evaluate once on the data that was collected.
* When a repository cannot be cloned or fetched because of missing permissions, the error now names the real cause instead of a generic clone failure. With no SCM credentials configured for the repository's owner, it explains that the operation ran anonymously and that a private repository needs credentials with access; with configured credentials, it explains that the repository either does not exist or the credentials have not been granted access to it — for a GitHub App, the installation's Repository access settings. The diagnosis applies to Lunar configuration syncs (including `lunar hub pull`), plugin fetches via `uses:`, and repository syncing, on GitHub and GitLab alike.

### Bug fixes

* A collector's `after-json` hook now fires even when the component has no collected data yet for a commit: its conditions are evaluated against an empty Component JSON, so fallback collectors run instead of the dispatch failing and retrying. Previously a component whose only collectors were `after-json` fallbacks never ran them — on pull request commits this looked like the collector simply not firing. See [Collector data dependencies: the `after-json` hook](#product-after-json-collector-hook).
* The stored merged Component JSON now folds collection records oldest to newest, matching the SQL API's `components` view: when the same key is written more than once for a commit, the newest value wins, and array elements accumulate in chronological order without duplication. Previously the merged data could keep the oldest value for a re-written key — for example an environment marker re-stamped on the same commit during a promotion — and could reverse or duplicate array entries. The stored data is recomputed on each component's next collection.
* `lunar policy ok-release` and `lunar policy ok-pr` no longer wait until their timeout when there is nothing to wait for. A release check now settles for a monorepo component when the commit touched no component in the repository at all, and both checks now answer success immediately — with a message that no policies gate the check — when no policy at a blocking enforcement level applies to the component. When a gating policy exists but has not run yet, the check still waits.
* On the runs dashboard, a run's component link now opens the component details of the specific monorepo sub-component that produced the run, instead of the repository-level component. See [Monorepo support: path-scoped components](#product-monorepo-path-scoped-components).

## 2026-07-21 <a href="#product-2026-07-21" id="product-2026-07-21"></a>

### Improvements

* A collector's `after-json` hook now honors `clone-code: true`: the collector runs with a checkout of the component's repository, pinned to the commit whose collection settled, so a source-scanning fallback — for example an SBOM generator, a filesystem vulnerability scan, an IaC scan, or a secrets scan — can run against the source tree when nothing else produced the data. Because the checkout is pinned to the settled commit rather than the branch head, the tree always matches the merged Component JSON the collector reads. An `after-json` collector without `clone-code` still runs without a checkout, unchanged. See [Collector data dependencies: the `after-json` hook](#product-after-json-collector-hook).
* A failing check's individual assertion messages in the Lunar comment and status posted on a pull request — on GitHub and GitLab alike — are now truncated at 1000 characters with a `… (truncated)` marker, so a single very long message (for example, one enumerating a long list of vulnerabilities) can no longer push the comment past GitHub's size limit and prevent it from being posted; the full message remains available through the dashboard link in the comment.
* The Hub now shares its cache of policy input bundles — the packaged component data a policy run fetches before executing — across all Hub replicas, so a bundle built once is reused everywhere instead of being rebuilt independently by each replica. Previously, on installations running multiple Hub replicas, a large burst of concurrent policy runs could overload the Hub with redundant bundle builds, causing bundle fetches to time out with `failed to bundle` errors and leaving runs stuck initializing.

### Bug fixes

* A component whose configuration includes an `after-json` collector can no longer have its checks left pending indefinitely when concurrent policy evaluations race to trigger the collector for the same commit. The duplicate trigger — always harmless, since the collector fires at most once per component and commit — was treated as an internal error that prevented policy evaluation from finalizing; it is now tolerated and checks settle as expected. See [Collector data dependencies: the `after-json` hook](#product-after-json-collector-hook).
* A collector fired by an `after-json` hook on a GitLab or GitHub Enterprise Server component now runs against the component's actual repository host. Previously the dispatched run defaulted to `github.com`, so its result was recorded against the wrong host and the run never settled — leaving the component's checks, and any collector waiting on that data, pending indefinitely. Components hosted on github.com were unaffected. See [Collector data dependencies: the `after-json` hook](#product-after-json-collector-hook).
* Runs the Hub starts on its own — scheduled `cron` collector runs, code-collection reruns, and cataloger runs — now preserve the repository's host and SCM provider for components hosted outside github.com. Previously the Hub built a GitHub-style repository reference for a GitLab-hosted component, mis-routing clone authentication, and its collector and cataloger runs omitted the repository host from their records, so runs and the policy evaluation that follows could be attributed to a `github.com` repository of the same name on GitLab and GitHub Enterprise Server installations.
* Script runs that are executing when the Operator shuts down — during an upgrade or rollout — are now requeued, without consuming a retry attempt, for the replacement Operator to pick up, instead of being left stuck in a running state for around five hours before being retried.
* The Lunar status posted on a pull request — the GitHub check and the GitLab commit status alike — no longer reports a failure when a required check is legitimately skipped, for example a policy with nothing to evaluate on that commit. A skipped check previously counted as a failure, so the pull request showed a red failing check even though the merge gate and the Lunar comment correctly treated it as passing. Skipped checks are now counted separately and surfaced in the status title, such as "14 passed, 1 skipped of 15 required checks"; a real failure alongside a skip still fails the status.

## 2026-07-20 <a href="#product-2026-07-20" id="product-2026-07-20"></a>

### Improvements

* Catalog refreshes no longer enqueue a repository sync job for every component on every run: a sync job is now enqueued only when the repository is actually due for its periodic reconcile, and per-repository sync jobs are deduplicated over an hour instead of ten minutes. On large installations this removes tens of thousands of redundant background jobs per hour, and under GitHub rate limiting sync retries can no longer pile up into a backlog that delays code collection and policy evaluation.

### Bug fixes

* Repository syncing no longer fails repeatedly for archived GitHub repositories. Because an archived repository is read-only and emits no push or pull-request events, the Hub now skips installing its webhook with a warning instead of erroring, and the repository's commit and pull-request history syncs as usual.

## 2026-07-17 <a href="#product-2026-07-17" id="product-2026-07-17"></a>

### Features

#### Collector data dependencies: the `after-json` hook <a href="#product-after-json-collector-hook" id="product-after-json-collector-hook"></a>

Collectors can now depend on data rather than on a specific named upstream collector. A collector that declares an `after-json` hook with a Component JSON `path` — for example `.sbom` or `.sca` — fires once the component's collection has settled for a commit, after every other collector and CI workflow has finished, whether or not the path ended up populated. If another tool produced data at the path, the collector can enrich it regardless of which tool that was; if nothing did, the collector runs as a fallback and can supply the data itself.

An `after-json` collector fires at most once per component and commit, and runs without a repository checkout: it reads the component's accumulated data with `lunar component get-json` and submits results with `lunar collect`. The component's checks stay pending until the fired collectors finish, and a collector that fires but writes nothing still releases the gate, so checks cannot be left pending indefinitely. The hook is collector-only — it cannot be declared on a cataloger — and `path` is required, must start with `.`, and is rejected on any other hook type at configuration sync.

### Bug fixes

* The component details and pull-request dashboards now render GitLab-hosted components correctly: the repository link is labelled `gitlab` instead of `github`, repository and merge-request links resolve for GitLab's nested namespaces (`…/project/-/tree/<branch>`, `…/project/-/merge_requests/<n>`) instead of being truncated to a fixed three-segment path, and the component dashboard reads "MR" instead of "PR" for GitLab components. GitHub rendering is unchanged.
* During a burst of pushes, a commit's processing — code collection, catalogers, and the policy evaluation that follows — is no longer lost when GitHub times out the webhook delivery. The Hub now runs the processing to completion even after GitHub closes the connection at its roughly ten-second delivery limit; previously the disconnect cancelled the work mid-flight and, because GitHub does not retry failed deliveries, those commits were left without collected data or check results.
* The Queued tab on the runs dashboard now loads instead of failing with `permission denied for schema queue`; the read-only database role that backs the Grafana datasource was missing read access to the table of queued runs, so every query behind the tab was rejected.

## 2026-07-16 <a href="#product-2026-07-16" id="product-2026-07-16"></a>

### Bug fixes

* A scheduled CI run — a GitHub Actions `on: schedule` workflow or a Buildkite scheduled build — that runs collectors now merges its results into the component's existing component JSON, the same way a collector's scheduled `cron` runs do. Previously such a run was pinned to whatever commit CI checked out, so a scheduled pipeline (for example a CodeQL workflow on a cron trigger) could become the component's latest view with only its own partial data, dropping the other collectors' results and causing policies to evaluate against incomplete data until the next full collection.

## 2026-07-13 <a href="#product-2026-07-13" id="product-2026-07-13"></a>

### Bug fixes

* A Lunar configuration hosted on GitLab now syncs correctly: the Hub clones a `gitlab://` configuration URI the same way it clones `github://` ones, instead of treating the URI as a local path — which made `lunar hub pull` of a GitLab-hosted configuration fail with a git error regardless of credentials.
* The Hub now registers a repository's webhook before starting that repository's initial history sync, so commits pushed while the first sync is still running trigger code collection. Previously, on a newly tracked repository, the webhook was installed only after the sync completed — which can take minutes on a large history — and pushes made in that window were silently missed.

## 2026-07-11 <a href="#product-2026-07-11" id="product-2026-07-11"></a>

### Breaking changes

* A Lunar configuration in which two imports of the same type resolve to the same name — for example the same policy plugin imported twice via `uses:` with different `on:` targets — now fails the configuration sync with an error naming the conflict; give each duplicate import a unique `name:` to disambiguate. A collector and a policy may still share a name. Duplicates that were synced before this change are renamed automatically with a short unique suffix, preserving their run history; add explicit `name:` values to restore meaningful names.

### Bug fixes

* Checks from policies at the `block-release` enforcement level no longer appear in the Lunar comment and status posted on a pull request — on GitHub and GitLab alike — since they gate releases rather than merges; previously they inflated the PR's check count so it did not match the dashboard. Results posted for release commits still include them.
* Dashboards no longer duplicate a component's checks when the component currently resolves to more than one latest commit — for example while a burst of commits is being processed, or when a component spans multiple repositories. Previously every check row was repeated once per commit, transiently inflating check counts and the component score.
* A policy imported more than once — for example once at `score` and once at `block-release` enforcement — now shows one check row per applying import, each with its own enforcement level, on the component's Checks tab and in the SQL API `checks` and `checks_latest` views. Previously such checks were collapsed to a single enforcement and could appear as identical duplicated rows, so the enforcement shown was unreliable. Because the component score counts these rows, scores can shift for components that import the same policy multiple times.
* Closed and merged pull requests no longer remain shown as Active on the component details dashboard or reported as open by the SQL API `prs` view. Previously, a pull request that closed more than a few hours after it was opened could keep a stale open status in materialized data indefinitely; the refresh window is now keyed on when a pull request last changed rather than when it was created, and stale rows are rebuilt automatically on the first hourly materialization pass after upgrading. Pull requests backfilled by a repository sync now also appear in the view immediately instead of being skipped.
* A component's `.meta` declared in the Lunar configuration (`components.<id>.meta` in `lunar-config.yml`) now reaches collectors and policies through the `LUNAR_COMPONENT_META` environment variable; previously configuration-declared metadata was silently dropped during the configuration sync, and only cataloger-emitted metadata was surfaced.
* The SQL API `checks` and `checks_latest` views no longer include orphaned rows whose `policy_id` refers to a policy name that no longer exists in any version of the Lunar configuration; these stale rows, left behind when duplicated policy names were renamed to stable ones, are removed automatically while all still-valid check history is preserved. Dashboards were not affected.
* Checks queried through the SQL API no longer show a duplicate row per Lunar configuration version for a policy that was imported multiple times under the same name. The `~` suffix that disambiguates such imports — added for configurations that predate the requirement for a unique `name:` per duplicate import — is now derived from the import's own definition (type, name, enforcement, initiative, and targeting) instead of changing with every configuration publish, so each import keeps one stable name and one checks row. Existing suffixed names change once as part of this repair.

## 2026-07-09 <a href="#product-2026-07-09" id="product-2026-07-09"></a>

### Features

* Collectors and policies now receive the component's catalog `.meta` — an arbitrary key-to-value map, for example `pagerduty/service-id` — as a JSON object in the `LUNAR_COMPONENT_META` environment variable, set only when the component has metadata. Plugins can use it for per-component configuration, such as mapping a component to the service identity it has in an external tool.

### Bug fixes

* `lunar policy ok-release` no longer times out waiting for policy evaluations when the build and the release step run as jobs in the same GitHub workflow file.

## 2026-07-07 <a href="#product-2026-07-07" id="product-2026-07-07"></a>

### Bug fixes

* The runs dashboard now attributes each run to the monorepo sub-component that produced it instead of collapsing all runs into a single repository row, and drilling in from a sub-component shows its runs instead of no data. See [Monorepo support: path-scoped components](#product-monorepo-path-scoped-components).
* A component that references an undeclared domain no longer causes the entire catalog to be dropped. A reference authored in the Lunar configuration now fails the sync with an error naming the component and domain before anything is published, and a cataloger-discovered reference drops only that component while the rest of the catalog persists.

## 2026-07-02 <a href="#product-2026-07-02" id="product-2026-07-02"></a>

### Features

* Collectors, catalogers, and policies can now declare a `size` (`small`, `medium`, `large`, or `xlarge`) in the Lunar configuration; the size selects the script-runner pod's resource profile and how densely runs are packed into pods. Scripts without a declared size keep the previous defaults, unchanged.
* `lunar-config.yml` can now be split for readability: a file may contain multiple YAML documents separated by `---`, and every `*.yml`/`*.yaml` file in a sibling `lunar-config.d/` directory is loaded and merged in filename order. A key defined twice across documents is an error rather than a silent overwrite, and a single-file configuration keeps working unchanged.

### Improvements

* A cataloger `component-repo` hook that sets `clone-code: true` now runs with a checkout of the component's repository at the triggering commit, so it can read repository files such as `catalog-info.yaml` or `CODEOWNERS` from a working tree instead of fetching them through an API.
* Code collection triggered by SCM webhooks now runs as a durable queued job, so a Hub restart mid-collection no longer loses the collection or the policy evaluation that follows it.

### Bug fixes

* Dashboard links to components, commits, and pull requests now resolve correctly for GitHub Enterprise Server hosts and for monorepo sub-components; previously GHES links were broken and sub-component links pointed at the wrong repository path.
* An empty push (a commit with no changed files) no longer triggers code collectors for every component in a monorepo; it now runs none. See [Monorepo support: path-scoped components](#product-monorepo-path-scoped-components).

## 2026-06-23 <a href="#product-2026-06-23" id="product-2026-06-23"></a>

### Features

#### Buildkite support <a href="#product-buildkite-support" id="product-buildkite-support"></a>

Lunar now supports Buildkite as a CI provider alongside GitHub Actions. The CI/CD Tracer traces Buildkite builds through a Buildkite agent `command` hook, and the Hub ingests Buildkite build webhooks so policies that depend on CI results wait for the build to finish instead of finalizing early. Builds are associated with their pull requests, a finishing build re-evaluates the affected policies, and in monorepos pull-request builds are scoped to the components whose files actually changed.

#### GitHub Enterprise Server and multi-organization support <a href="#product-github-enterprise-server-support" id="product-github-enterprise-server-support"></a>

A single Lunar Hub can now serve GitHub Enterprise Server (GHES) organizations alongside github.com and GitHub Enterprise Cloud, and front multiple organizations that each install their own Lunar GitHub App — one entry per owner, with a per-App host and base URL. The CI/CD Tracer resolves the GitHub host from the CI run environment, so GHES runs are attributed to the right component with no extra configuration, and pull-request checks post back to the GHES host.

#### Monorepo support: path-scoped components <a href="#product-monorepo-path-scoped-components" id="product-monorepo-path-scoped-components"></a>

Components can be scoped to subdirectories of a repository with a configurable `paths` list, so a CI run or commit is attributed only to the sub-components whose files actually changed. An entry ending in `*` is a prefix match (`services/api/*` matches anything under `services/api/`), an entry without one must equal the changed path exactly (such as `go.mod`), and a component named after a monorepo subdirectory gets an implicit `<subdir>/*` pattern automatically. Path-based attribution applies to pull requests and pushes on GitHub Actions, and to pull-request builds on Buildkite.

### Bug fixes

* Running and queued collector runs on the Collector details dashboard now show in-progress indicators instead of being displayed as failures.

## 2026-06-17 <a href="#product-2026-06-17" id="product-2026-06-17"></a>

### Features

#### Out-of-band collection <a href="#product-out-of-band-collection" id="product-out-of-band-collection"></a>

`lunar collect` can now be called from outside a traced CI run — for example from a CD pipeline — to attach additional data to a component's Component JSON at a specific commit, using `--component` and `--sha`. This makes it possible to record facts that only become known after CI, such as the release tag or image published by a deploy job. The Hub records the write as an external collection and re-evaluates the component's policies for that commit.

* A collector with a `cron` hook can now run against open pull request heads, not just the default branch — opt in with `runs_on: [prs, default-branch]`, or `[prs]` for PR heads only. Each scheduled run is recorded against that PR's head commit, so a scheduled dependency check can start failing a pull request after a new CVE is published even though its code has not changed.

### Improvements

* The runs listing dashboard now refreshes incrementally as results arrive — a run change appears within seconds instead of about half a minute — and components whose most recent run is older than ten days are no longer hidden from the listing.

### Bug fixes

* An interrupted configuration sync — or a single unreachable repository — can no longer leave the component catalog empty; an unresolvable component is skipped with a warning instead of discarding all of them.
* A Lunar configuration change now becomes visible only once it is fully processed, closing a window during which CI collections for the affected components were silently dropped.
* In a monorepo, components whose paths are untouched by a commit no longer show pending checks indefinitely while waiting for code collectors that will never run; their checks now settle.
* A policy that resolves to no container image now fails the Lunar configuration sync with an error naming the policy and how to fix it, instead of being silently skipped at execution time.
* Queued script runs are now shown with a waiting indicator on the Script runs dashboard instead of the same error mark as a failed run.
* The Created column on the Script runs dashboard's Queued tab now shows how long ago a run was created instead of a raw timestamp.
* Collector and cataloger runs abandoned by a failed dispatch are now cleaned up automatically instead of counting as queued forever in the dashboards.

## 2026-05-27 <a href="#product-2026-05-27" id="product-2026-05-27"></a>

### Improvements

* Policy evaluation is more resilient: policy runs and the posting of their GitHub check results now survive Hub restarts, a run can no longer be lost between being recorded and being scheduled, duplicate policy runs for the same collection are prevented, and interrupted runs are cleaned up automatically instead of appearing queued forever.
* The Lunar check on a pull request now states explicitly when no PR-blocking checks are configured, instead of reporting "0 out of 0 required checks passed", and mentions failing or pending non-blocking checks in its title and summary while the check itself remains successful.
* Public repositories — such as Earthly's public plugin library — can now be referenced from the Lunar configuration without a GitHub App installation on their organization; Lunar falls back to anonymous access when no App is configured for that owner. Private repositories still require an App installation.
* The Queued tab on the runs dashboard now shows the full backlog of script runs waiting to execute — including runs queued but not yet dispatched — with pagination and an accurate filter by script type.
* The runs dashboards and their filters now show only scripts that are part of the current Lunar configuration, so collectors, policies, and catalogers that have been removed no longer clutter listings, filter dropdowns, or error counts.
* Runs listings filtered to narrow time windows load much faster on large installations — around 300× faster for a time-only filter and 6.5× faster for a one-day window.
* The Hub now remembers which repository webhooks it has already installed and skips redundant GitHub calls, so webhook setup on catalogs with hundreds of repositories stays under GitHub's rate limit; a webhook installation that fails on a transient GitHub error is retried instead of leaving the repository without change events.

### Bug fixes

* The setup-errors warning banner on the component and pull-request dashboards now counts only errors from the last ten days, matching the runs listing it links to; clicking the banner no longer lands on an empty list.
* On the runs dashboard, the collector and policy links from a component page now land on populated rows, queued policy script names link to the policy's details, and the Queued tab's Created column shows how long ago a run was created instead of a raw timestamp.
* A cataloger run that finishes after its Lunar configuration version has been superseded can no longer cause pushes to be evaluated against the outdated configuration; policies added in a configuration update now dispatch for new commits immediately.

## 2026-05-17 <a href="#product-2026-05-17" id="product-2026-05-17"></a>

### Improvements

* Repositories with the same name under different GitHub owners are now tracked as distinct repositories, so one Lunar installation can monitor same-named repositories across multiple organizations without their data mixing.

### Bug fixes

* The catalogers dashboards no longer fail to render when a cataloger's output contains a non-object `domains` or `components` field; such entries now count as empty instead of erroring the whole panel.
* Script runs whose container image cannot be pulled, or whose pod setup keeps failing, are now marked failed after a bounded number of retries instead of retrying indefinitely and appearing stuck in the queue.

## 2026-05-15 <a href="#product-2026-05-15" id="product-2026-05-15"></a>

### Breaking changes

* The badge service that rendered SVG status badges from policy results is removed. Policy status is surfaced through the Lunar check on pull requests and through the dashboards, which had already replaced badges in practice.

### Improvements

* Policy checks now finalize without waiting for CI results that will never arrive: on repositories with no CI workflows, on commits marked `[skip ci]`, and on commits where CI exists but GitHub emits no workflow run. Previously such commits could show pending checks indefinitely.
* Repository webhook setup and repository syncing now run in the background after a Lunar configuration sync instead of slowing it down, and the Hub removes its webhook from a repository when the last component referencing that repository leaves the catalog.
* Dashboard pages load significantly faster on large installations — the component checks query is around 12× faster and the components listing paginates before joining check data — and collector run counts on the collectors listing now match the runs page.
* Dashboards are reorganized on Grafana 13.1: the collector, policy, initiative, and domain detail pages group their content into tabs with live counts, the components listing adds pagination and filters by component, domain, owner, and tag, the domain page shows its components directly, and the runs listings add a Queued tab showing runs waiting to execute.
* Lunar Hub now authenticates to GitHub exclusively through GitHub App installations; its legacy personal-access-token authentication path is removed. App authentication carries finer-grained permissions and supports zero-downtime key rotation.
* SQL API tables now materialize hourly instead of every four hours, so queries against materialized data reflect recent activity sooner.

### Bug fixes

* A Lunar configuration update no longer causes components to return an empty Component JSON until new collections arrive; data collected under earlier configuration versions remains visible.
* Cataloger results are no longer silently dropped when catalogers run in Kubernetes; cataloger output — including output submitted in multiple batches — now reaches the catalog correctly.


# 2025

## 2025-12-19 <a href="#product-2025-12-19" id="product-2025-12-19"></a>

### Features

#### Containerized script execution <a href="#product-containerized-script-execution" id="product-containerized-script-execution"></a>

Collectors, policies, and catalogers can now run inside Docker containers, isolating script execution and making script dependencies reproducible.

The image is chosen per script with the `image` field, falling back to plugin-level defaults and then to global `default_image` settings in the Lunar config, with separate defaults for CI collectors, non-CI collectors, policies, and catalogers. Scripts without an image continue to run natively, and it is common to leave CI collectors on `native` so they can access the CI environment directly.

## 2025-10-20 <a href="#product-2025-10-20" id="product-2025-10-20"></a>

### Features

* Dashboards now track component check scores over time, with a trend sparkline for each component in the components list and a score-over-time chart in the component detail view.

## Early Access (2025-09-03) <a href="#product-early-access" id="product-early-access"></a>

### Features

#### Lunar available in early access <a href="#product-early-access-launch" id="product-early-access-launch"></a>

Lunar is now available in early access, with its first public release published on September 3, 2025.

Lunar puts guardrails around the software development lifecycle. Software is organized into domains and components; collectors gather SDLC metadata about each component into its Component JSON; and policies evaluate that metadata to produce pass-or-fail checks.

The early-access platform includes the collection and policy engine with code, cron, and CI hooks; CI pipeline instrumentation for GitHub Actions through the Lunar CI agent (now the CI/CD Tracer); and check results posted to pull requests as commit statuses and comments. It also includes dashboards for components, checks, script runs, and initiatives; a read-only SQL API over the collected data; and a plugin system for reusable collectors, policies, and catalogers, with Bash and Python SDKs.


# Self-hosted

Self-hosted release notes cover Helm chart releases, installation and upgrade requirements, configuration, operations, and the artifacts included with each chart. Entries lead with the release date and identify the Helm chart version, Lunar images version, and linked [Product updates](/release-notes/product) included in that Self-hosted release.

Use the annual pages in this section for the complete released chart history. See [Install Walkthrough](/install/lunar-hub/self-hosted/install-walkthrough) and [Day-2 Operations](/install/lunar-hub/self-hosted/day-2-operations) for current operating guidance.


# 2026

## 2026-09-15 <a href="#self-hosted-2026-09-15-1" id="self-hosted-2026-09-15-1"></a>

**Helm chart version:** `4.0.0`\
**Lunar images version:** `4.0.0`\
**Product updates included:** [2026-09-15](/release-notes/product/2026#product-2026-09-15)

### Product updates

* **Migration:** [Whole-report checks templates](/release-notes/product/2026#product-2026-09-15)
* **Security:** [Unprotected-ref collection data ignored on the default branch](/release-notes/product/2026#product-2026-09-15)
* **Feature:** [CI job identity for GitHub Actions and GitLab CI](/release-notes/product/2026#product-ci-job-identity)
* **Feature:** [Choosing which configuration file a Hub loads](/release-notes/product/2026#product-configuration-entry-point)
* **Feature:** [Per-person SQL API credentials](/release-notes/product/2026#product-per-person-sql-api-credentials)
* **Feature:** [Signing in to Lunar as yourself](/release-notes/product/2026#product-personal-sign-in)
* **Improvement:** [Faster component materialization refreshes](/release-notes/product/2026#product-2026-09-15)
* **Improvement:** [Faster refresh of SQL API pull-request data](/release-notes/product/2026#product-2026-09-15)
* **Improvement:** [Stable GitHub App for new posts](/release-notes/product/2026#product-2026-09-15)
* **Improvement:** [Forge-specific header for the PR details link column](/release-notes/product/2026#product-2026-09-15)
* **Improvement:** [Faster refresh of the SQL API components data](/release-notes/product/2026#product-2026-09-15)
* **Improvement:** [Faster Script runs dashboard when filtered](/release-notes/product/2026#product-2026-09-15)
* **Improvement:** [Verified actor and role on CLI bypasses](/release-notes/product/2026#product-2026-09-15)
* **Bug fix:** [Checks coverage kept while a component's repository association is pending](/release-notes/product/2026#product-2026-09-15)
* **Bug fix:** [Dashboard scorecards exclude components and domains removed from the catalog](/release-notes/product/2026#product-2026-09-15)
* **Bug fix:** [Existing GitHub posts are edited by the App that created them](/release-notes/product/2026#product-2026-09-15)
* **Bug fix:** [GitHub reports cover every matched component](/release-notes/product/2026#product-2026-09-15)
* **Bug fix:** [One failing posting surface no longer suppresses the others](/release-notes/product/2026#product-2026-09-15)
* **Bug fix:** [Component filter on the Script runs dashboard](/release-notes/product/2026#product-2026-09-15)

### Migrations and upgrade notes

* Upgrading runs a database migration that adds an index on the collection-progress table (`hub.code_collection_dispatch_progress`), covering component, commit SHA, pull request, and job, which the GitHub report lookups use to find a component's newest progress row without scanning its history. The index is built with `CREATE INDEX CONCURRENTLY`, so reads and writes continue throughout, but the pre-rollout `lunar-hub-migrate` Job waits for the build to finish, so on a large database this upgrade can take noticeably longer than usual; a retry after an interrupted build drops the invalid leftover index and rebuilds it. No operator action is required.
* A host-wide GitLab token entry — one with no `group` — needs a Hub image that understands it. Support first shipped in the `3.21.0` images, which the chart's default image tags include, but on an installation that pins `hub.image.tag` below `3.21.0` a group-less entry fails the Hub's configuration validation and the Hub refuses to start: the mismatch is loud rather than silent, but it is still a failed rollout. Keep `group` on every entry until the Hub image is at `3.21.0` or later. See [Instance-wide GitLab tokens and token pools](/release-notes/product/2026#product-gitlab-host-wide-token-pools).
* This upgrade adds an index on the Operator's internal job-queue table (`operator_queue.river_job`) covering partition-keyed jobs, so claiming jobs for a partition stays a narrow lookup on a queue that has churned through a large backlog. It is built with `CREATE INDEX CONCURRENTLY`, so queue reads and writes continue throughout, but the pre-rollout `lunar-hub-migrate` Job waits for the build and then analyzes the table — on an installation with a very large job backlog, expect that Job, which gates the rollout, to take noticeably longer than usual. No operator action is required, and an interrupted build is dropped and rebuilt when the Job is retried.
* Minting personal SQL API credentials requires `CREATEROLE` on the Hub's database role — and, on PostgreSQL 16 and later, `ADMIN OPTION` on the shared SQL API role; an install that pre-creates `sqlapi_user` to avoid granting `CREATEROLE` should set `HUB_SQLAPI_USER_CREDENTIAL_TTL=0`, otherwise every signed-in person's request for a connection string fails with a precondition error naming the missing privilege. A connection pooler in front of the SQL API must resolve users dynamically — PgBouncer `auth_query`, rather than a static user list, which knows only the shared user — or personal roles are rejected at the pooler. The service token's shared connection string is unaffected in both cases. See [Per-person SQL API credentials](/release-notes/product/2026#product-per-person-sql-api-credentials).
* Posting of policy results now has its own per-Hub-replica worker cap, `HUB_MAX_WORKERS_POLICY_POST` (default `20`; `0` means unlimited), set through `hub.extraEnv`; posting previously shared `HUB_MAX_WORKERS_POLICY`, so an installation that raised that value keeps the raised policy-execution capacity but not a raised posting capacity, and should set the posting cap explicitly during the upgrade. Posting work now runs on its own `policy_post` queue, with one active posting job per repository across replicas while different repositories post concurrently, so monitor that queue's backlog and oldest job age alongside the existing worker settings.

### Security

* The Lunar images upgrade `golang.org/x/crypto` to v0.56.0, resolving the high-severity GO-2026-6354 (CVE-2026-78662) and GO-2026-6355 (CVE-2026-56855) denial-of-service advisories in `x/crypto/ssh` flagged by software-composition analysis, and pin `golang.org/x/mod` to v0.40.0 for the `x/mod/sumdb` tile-verification advisories GO-2026-6179 and GO-2026-6180. Alongside that, the bundled `yq` moves to v4.53.6 in the Hub and agent images, the agent image's Go toolchain moves to go1.25.14, and the `grpcurl` bundled in the dashboards image is rebuilt from a newer upstream commit with a current Go toolchain and `golang.org/x/crypto` v0.56.0; the agent image now copies that same pinned build instead of installing an unpinned `grpcurl` release, which resolved to v1.9.3 and its `google.golang.org/grpc` v1.61.0. Upgrading the chart pulls the patched images; their behavior is unchanged.

### Features

#### Hub settings for CI job identity <a href="#self-hosted-ci-identity-hub-settings" id="self-hosted-ci-identity-hub-settings"></a>

`HUB_AUTH_AUDIENCE` is the bare host CI jobs must name as the audience of the OIDC tokens they request. It defaults to the host of `HUB_PUBLIC_BASE_URL`, is rejected if it carries a scheme or a path, and while it resolves to empty the Hub accepts only the service token, as before.

For jobs to trade a token for a session that outlives it, mount a file holding at least 32 random bytes and point `HUB_AUTH_SESSION_KEY_PATH` at it; every Hub replica must read the same key, and the Hub refuses to start if the file cannot be read or is shorter than that. Without a session key the Hub still verifies CI tokens presented directly, and the exchange fails with `FailedPrecondition`.

`HUB_AUTH_CI_SESSION_TTL` sets how long an exchanged session lasts, default `6h`. `HUB_LUNAR_CONFIG_REPO` pins the repository holding `lunar-config.yml` as a Lunar git URI, for example `github://owner/repo` or `gitlab://host/group/repo`, so that repository's protected pipeline can install a first configuration before the Hub has recorded which repository its configuration comes from.

The issuers the Hub trusts are derived from the forge configuration already in place: GitHub Actions for each configured GitHub host and GitLab CI for each host in `HUB_GITLAB_TOKENS`. The Hub logs each trusted issuer and the audience at startup.

See [CI job identity for GitHub Actions and GitLab CI](/release-notes/product/2026#product-ci-job-identity).

#### Accepting user logins: `HUB_AUTH_OAUTH_APPS` <a href="#self-hosted-forge-oauth-apps-for-user-logins" id="self-hosted-forge-oauth-apps-for-user-logins"></a>

The Hub accepts personal logins only once an operator registers a forge OAuth app and lists it in the new `HUB_AUTH_OAUTH_APPS` environment variable: a JSON array with one entry per forge and host, each carrying `forge` (`github` or `gitlab`), `host`, `client_id`, and — for GitHub only — an optional `client_secret_path`. A session key must also be configured through `HUB_AUTH_SESSION_KEY_PATH`, or the Hub cannot mint sessions. Left unset, nobody can sign in, and the service token and CI job credentials are unaffected.

The Hub validates the list at startup and refuses to start on a duplicate forge and host pair, a forge other than `github` or `gitlab`, a missing client id, a `client_secret_path` on a GitLab entry, or a secret file that cannot be read or is empty.

On GitHub the client secret is what decides how ownership of a presented token is proven and whether the Hub can revoke it: with a secret, the Hub asks GitHub's authoritative application-token endpoint and can revoke a token when someone runs `lunar logout`; without one, it verifies the `X-OAuth-Client-Id` header GitHub returns for the token and logout tells the person where to revoke access themselves. GitLab needs no secret — ownership is proven through `/oauth/token/info` — so the GitLab application must be registered as non-confidential.

See [Signing in to Lunar as yourself](/release-notes/product/2026#product-personal-sign-in).

#### Host-wide GitLab token entries <a href="#self-hosted-gitlab-host-wide-token-entries" id="self-hosted-gitlab-host-wide-token-entries"></a>

`hub.gitlab.tokens[*].group` is now optional. An entry without it is host-wide: the token serves every group on `host` that no `group` entry claims, which is how an instance service account on a self-managed or GitLab Dedicated instance is meant to be used — one account, made a Maintainer of every group Lunar serves, one token, and inviting the account to a new group onboards it with no chart change.

`group` entries always take precedence over the host-wide one. A host-wide entry's token is looked up at `<host>.token` inside `hub.gitlab.tokensSecret`, with `host` defaulting to `gitlab.com`, so the Secret needs one data key named after the instance hostname rather than after a group.

See [Instance-wide GitLab tokens and token pools](/release-notes/product/2026#product-gitlab-host-wide-token-pools).

* The `manifest-url` input of the config-sync workflow — and the `<repo>` argument behind it — accepts a configuration path, `github://<org>/<repo>/<config-path>@<branch>`, so a development Hub and a production Hub can pull different configuration files from one repository and branch. A Hub older than this release ignores the path and loads the root `lunar-config.yml`, so upgrade every Hub that shares the repository before moving its configuration to a named entry point. See [Choosing which configuration file a Hub loads](/release-notes/product/2026#product-configuration-entry-point).
* `hub.gitlab.tokens[*].tokenFile` names the data key inside `hub.gitlab.tokensSecret` that holds an entry's token, overriding the derived `<group>.token` or `<host>.token` — the GitLab counterpart of `hub.github.apps[*].privateKeyFile`, and needed for the same reason, since two entries on one scope (a pool the Hub spreads reads across) would otherwise derive the same filename. It also lets one token back several scopes, because a host-wide entry and a `group` entry may name the same file, and, because the data key no longer derives from the group, an entry with `tokenFile` may bind a token to a subgroup path such as `platform/checkout`, which the Hub has matched by longest prefix all along but the chart could not express. See [Instance-wide GitLab tokens and token pools](/release-notes/product/2026#product-gitlab-host-wide-token-pools).
* `HUB_SQLAPI_USER_CREDENTIAL_TTL` sets how long a personal SQL API credential lives and defaults to `720h`; `0` disables personal credentials, so a signed-in person asking for a connection string is refused with a message pointing at the service token, and a negative value is rejected at startup. The Hub records one row per role it has minted in `hub.sql_credentials` — never a password — and exposes it through the SQL API `sql_credentials` view; an hourly sweep drops roles more than seven days past their expiry and stamps the row instead of deleting it, so past access stays auditable. See [Per-person SQL API credentials](/release-notes/product/2026#product-per-person-sql-api-credentials).

### Improvements

#### Pooler authentication for personal SQL API roles <a href="#self-hosted-personal-sql-api-role-verifiers-for-pooler-authentication" id="self-hosted-personal-sql-api-role-verifiers-for-pooler-authentication"></a>

The Hub now keeps the SCRAM-SHA-256 verifier of every personal SQL API role — the Postgres role minted when a signed-in person runs `lunar sql connection-string` — in a new `hub.sql_credential_verifiers` table, added by a database migration that runs on upgrade. A connection pooler in front of the SQL API can read that table as the source for its `auth_query` and so authenticate roles that were created after it started, which is otherwise impossible on managed Postgres where `pg_authid` is closed to everyone but the provider's own administrator — on Amazon RDS, even `rds_superuser` is refused. Expose it to the pooler through a `SECURITY DEFINER` lookup function owned by the Hub's database role: the table itself is revoked from the shared `sqlapi_user` role, from the personal roles that inherit from it, and from the Grafana datasource role, exactly as `hub.secrets` is. Each row is written in the same transaction that sets the role's password, and removed when the credential is revoked or when the expiry sweep drops the role, so a revoked credential stops working through the pooler as well. Because a verifier is what `pg_authid` holds for the same role, a logical dump of the Hub database now carries live verifiers: treat such a dump as you would a copy of `hub.secrets`. See [Per-person SQL API credentials](/release-notes/product/2026#product-per-person-sql-api-credentials).

* GitLab token validation no longer rejects two entries naming the same group. A pool is exactly that, so the chart now fails only on an entry that repeats an earlier one exactly — same `host`, same `group` or both host-wide, and the same token file — while a repeated group with distinct `tokenFile`s renders as a pool. Each entry's resolved token file is also validated as a Kubernetes Secret data key, and values with `group` on every entry and no `tokenFile` render byte-for-byte as before. See [Instance-wide GitLab tokens and token pools](/release-notes/product/2026#product-gitlab-host-wide-token-pools).

### Deprecations

* A licence whose telemetry block authenticates to Elasticsearch with an API key is deprecated, and the Hub now logs a warning at boot naming it. With such a licence nothing changes: the Hub keeps handing that one tenant-wide, never-expiring key to every caller that asks where to ship its logs — the CI Tracer, the Operator, and the script pods it creates. Ask Earthly to re-issue the licence with an Elasticsearch user login instead; the Hub then authenticates as that user and mints each caller a key of its own, valid for 12 hours and restricted to appending to the tenant's log and heartbeat indices, and the tenant-wide credential never leaves the Hub. Installing the re-issued licence is the only action required — update the licence secret and restart the Hub, which then logs that it is minting a short-lived key per caller; long-running callers renew their keys on their own.

## 2026-09-08 <a href="#self-hosted-2026-09-08-1" id="self-hosted-2026-09-08-1"></a>

**Helm chart version:** `3.21.2`\
**Lunar images version:** `3.21.2`\
**Product updates included:** [2026-09-08](/release-notes/product/2026#product-2026-09-08)

### Product updates

* **Bug fix:** [Lunar checks appear once policy results exist](/release-notes/product/2026#product-2026-09-08)
* **Bug fix:** [Retries for transient script image-pull failures](/release-notes/product/2026#product-2026-09-08)

### Bug fixes

* The migration that creates the `hub.bypasses_resolved` view can now be applied again after a failed rollback rewound `schema_version` past it; it previously failed because the view already existed, leaving the upgrade unable to proceed.
* Setting `hub.replicaCount: 0` now renders the Hub Deployment with zero replicas instead of silently falling back to one, so the serving Hub can be kept stopped during a GitOps-managed maintenance window; the `lunar-hub-migrate` pre-install and pre-upgrade Job is independent and still runs while the Deployment is suspended.

## 2026-09-07 <a href="#self-hosted-2026-09-07-1" id="self-hosted-2026-09-07-1"></a>

**Helm chart version:** `3.21.0`\
**Lunar images version:** `3.21.0`\
**Product updates included:** [2026-09-07](/release-notes/product/2026#product-2026-09-07)

### Product updates

* **Feature:** [Instance-wide GitLab tokens and token pools](/release-notes/product/2026#product-gitlab-host-wide-token-pools)
* **Improvement:** [Faster code-collection doneness check on commits with large run histories](/release-notes/product/2026#product-2026-09-07)
* **Improvement:** [Lunar reports a commit as running while it collects](/release-notes/product/2026#product-2026-09-07)
* **Improvement:** [`only_failures` spelling for `pr_comments.mode`](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [Consistent bypass liveness in `lunar policy bypass-ls`](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [Completed GitHub workflow runs stay completed](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [Gates no longer wait on policies that cannot block them](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [A pending check no longer posts the `only_failures` comment](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [Policy bundle downloads no longer fail with expired credentials](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [`pr_comments.mode` now takes effect](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [`meta` and `failureText` on a `uses:` policy entry](/release-notes/product/2026#product-2026-09-07)
* **Improvement:** [Lunar status shows as running while collection is under way](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [A pending check no longer breaks the quiet results comment](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [`only_failures` results-comment mode now takes effect](/release-notes/product/2026#product-2026-09-07)
* **Bug fix:** [Completed GitHub Actions runs stay terminal](/release-notes/product/2026#product-2026-09-07)

### Security

* The three script-pod images (`lunar-snippet-operator`, `lunar-snippet-init`, `lunar-snippet-sidecar`) and the dashboards deploy image (`lunar-dashboards`) now build on Alpine 3.24.1 and upgrade their OS packages at build time, clearing the fixable critical CVEs image scanners flagged on the previously published tags: `libssl3` and `libcrypto3` 3.5.7-r0 (CVE-2026-63073 and CVE-2026-75803) in all four images, plus eight criticals in `curl` 8.20.0-r0 in the dashboards image, which the Alpine 3.23 branch never fixed. Upgrading the chart pulls the patched images; their behavior is unchanged.
* The Lunar images upgrade `google.golang.org/grpc` to v1.83.2, resolving the high-severity CVE-2026-84304 flagged by software-composition analysis; `golang.org/x/net` moves to v0.58.0 as an indirect dependency of the same upgrade. Upgrading the chart pulls the patched images; behavior is unchanged.
* The Hub images are now built against `google.golang.org/grpc` 1.83.2, which carries the fix for CVE-2026-84304; the update reaches a self-hosted install by upgrading to a chart release that ships these images.

### Features

* A `HUB_GITLAB_TOKENS` entry may now omit `group`, and repeating a scope — the same `group`, or the same `host` with no `group` — pools its tokens rather than failing startup as a duplicate; a configuration that names a `group` on every entry is unaffected. A group-less token must hold the Maintainer role everywhere Lunar reaches on that host, because Lunar does not fall back to another token when a call that account makes is refused. Entries pooled on the same scope must agree on `webhook_secret` and must not repeat the same token, or the Hub refuses to start with an error naming the scope, and the Hub now logs one `configured GitLab token pool` line per scope at startup — with `group=<host-wide>` for a group-less entry — so pool capacity can be confirmed without exposing credentials. See [Instance-wide GitLab tokens and token pools](/release-notes/product/2026#product-gitlab-host-wide-token-pools).

## 2026-09-02 <a href="#self-hosted-2026-09-02-1" id="self-hosted-2026-09-02-1"></a>

**Helm chart version:** `3.20.0`\
**Lunar images version:** `3.20.0`\
**Product updates included:** [2026-09-02](/release-notes/product/2026#product-2026-09-02)

### Product updates

* **Improvement:** [GitHub conditional-request cache renewal](/release-notes/product/2026#product-2026-09-02)
* **Improvement:** [Fewer GitHub calls for metadata-only pull-request events](/release-notes/product/2026#product-2026-09-02)
* **Improvement:** [Pull-request file lists reused across GitHub events](/release-notes/product/2026#product-2026-09-02)
* **Improvement:** [Repository sync skips unchanged branch history](/release-notes/product/2026#product-2026-09-02)
* **Improvement:** [Repository sync skips commit listing for unchanged pull requests](/release-notes/product/2026#product-2026-09-02)
* **Improvement:** [SQL API served from materialized tables](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Failing assertions render in the order the policy asserted them](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Results for script runs longer than 30 minutes](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Nightly coverage repair for materialized check data](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Pull-request file and commit reads use the GitHub App pool](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Pull requests with more than 100 commits](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Queued tab honors the dashboard filters](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Reopened pull requests are collected again](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Retries for out-of-memory and unobserved script runs](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Script runs survive an interrupted Kubernetes pod watch](/release-notes/product/2026#product-2026-09-02)
* **Bug fix:** [Very large pull requests no longer scoped on a truncated file list](/release-notes/product/2026#product-2026-09-02)

### Breaking changes

#### `HUB_SQLAPI_PASSWORD` in `hub.extraEnv` now fails the render <a href="#self-hosted-hub-sqlapi-password-rejected-in-extraenv" id="self-hosted-hub-sqlapi-password-rejected-in-extraenv"></a>

The chart owns `HUB_SQLAPI_PASSWORD` as of this release, and an install that still sets it under `hub.extraEnv` fails to render, with an error naming the values to move it to. Leaving both copies in place would let Kubernetes accept the duplicate and pick a winner by template ordering rather than by intent, and a GitOps install on server-side apply would reject the object outright on the duplicate key. The render aborts before Helm touches the cluster, so nothing is half-applied and there is nothing to roll back.

To keep the existing password, set `mode: secret` with `secretName` and `passwordKey` pointing at the same Secret your `extraEnv` entry referenced: nothing rotates, and connection strings already in use keep working. If you supplied the password inline as `value:` rather than through a `secretKeyRef`, create a Secret holding it first — the chart takes credentials only by reference, never as a literal value.

To hand the password to the chart instead, drop the `extraEnv` entry and let the default apply. The chart generates its own Secret under a different name, so the old one is left unreferenced and can be deleted afterwards as cleanup. The password rotates, so re-fetch it with `lunar sql connection-string` and update any client holding the old one.

Do not delete the old Secret first: deleting it destroys the only copy of the password, and with it the option of keeping it. Change the values first and clean up afterwards.

* The Hub no longer reads `HUB_SCOPED_MATERIALIZE_ENABLED` or `HUB_ASSOCIATE_COMPONENT_DEDUP_ENABLED`: both are now ignored wherever they are set, and catalog materialization is always scoped to what a change touched. An installation that set `HUB_SCOPED_MATERIALIZE_ENABLED=false` to fall back to the whole-catalog rebuild now runs the scoped path regardless, because that rebuild is removed — the fallback was never a clean rollback in any case, since it served a catalog snapshot frozen at the moment scoped materialization took over. Remove both variables from `hub.extraEnv` so nothing in your values claims an effect it no longer has. See [Scoped catalog materialization](/release-notes/product/2026#product-2026-08-11).

### Migrations and upgrade notes

#### SQL API view definitions move to the materialized projections <a href="#self-hosted-materialized-sql-api-serving-default" id="self-hosted-materialized-sql-api-serving-default"></a>

Upgrading installs the materialized-projection definitions of the SQL API views. The `lunar-hub-migrate` Job renders them by default now, after checking that an existing installation's projections are present and populated; when that check does not pass — for example while a projection is still being filled — the Job leaves the previous view definitions in place, completes successfully, and tries again on the next deploy. A database with no Lunar migration history is bootstrapped straight onto the new definitions, since it has no history to catch up on.

To stay on, or return to, the previous definitions, set `HUB_MAT_SERVING_ENABLED`, `HUB_MAT_POLICY_RUN_LATEST_ENABLED`, or `HUB_CHECKS_LATEST_SERVING_ENABLED` to `false` under `hub.extraEnv`, which the migrate Job inherits, and redeploy. Each switch is independent and there is no data migration in either direction.

Because the views no longer derive recent rows at query time, the materialization write path is what keeps them current: turning it off with `HUB_MAT_BACKGROUND_ENABLED=false` while serving from the projections leaves the SQL API answering from a snapshot that stops advancing, without erroring. The Hub logs that as a staleness hazard.

See [SQL API served from materialized tables](/release-notes/product/2026#product-2026-09-02).

#### Shared Postgres clusters must set `hub.db.sqlapiPassword.mode: unmanaged` before upgrading <a href="#self-hosted-sqlapi-password-unmanaged-required-on-shared-postgres" id="self-hosted-sqlapi-password-unmanaged-required-on-shared-postgres"></a>

If you pre-created `sqlapi_user` yourself because the Hub's database role cannot be granted cluster-wide `CREATEROLE` — the arrangement the self-hosted prerequisites describe for a shared Postgres cluster — set `hub.db.sqlapiPassword.mode: unmanaged` in your values before upgrading.

The new default is `generate`, which makes the migration issue `ALTER ROLE sqlapi_user PASSWORD …` against a role the Hub's database role has no authority over. Postgres refuses with `permission denied to alter role`, the SQL API code package is applied in a single transaction, and the migrate Job is a `pre-upgrade` hook, so the release aborts after the upgrade has already started. The chart cannot inspect the database role's privileges, so it cannot warn you.

Setting `mode: unmanaged` renders exactly what your install rendered before — no `HUB_SQLAPI_PASSWORD` in either the Hub Deployment or the migrate Job, and no `ALTER ROLE` — so nothing else about the install changes.

If you also run Grafana, `unmanaged` alone is not enough. `grafana_user` is created by the same mechanism with the same unguarded `ALTER ROLE`, and its password is chart-generated whenever `grafana.mode` is not `off`, with no equivalent opt-out. A shared-cluster install with Grafana enabled cannot complete a migration today, independently of this release; `grafana.mode: off` is the way through until that is addressed separately.

* Upgrading runs a database migration that drops the two retired whole-catalog snapshot tables, `hub.rel_catalog_json_items` and `hub.catalog_json`. Nothing has written or read them since scoped catalog materialization became the default: the current catalog is derived from the live component and domain rows, and catalog history is served from the captured versions behind the SQL API `catalog` and `catalog_latest` views, so nothing a query or a dashboard can reach is lost and no operator action is required. Dropping the tables returns their space to the operating system straight away rather than leaving it allocated, which on a long-lived installation can be considerable — 14.6 GB on the installation this was sized against. Let the rollout finish promptly: a Hub pod from the previous release that is still running after the pre-rollout `lunar-hub-migrate` Job has applied the migration errors if it touches the dropped tables, for example on a retention sweep, and recovers once it is replaced by a pod from this release.
* Setting `HUB_REPO_SYNC_SWEEP_INTERVAL` to `0` now stops repository reconciliation altogether, and the Hub starts without complaint. With the whole-catalog rebuild gone, nothing else re-enqueues a repository sync for a component that no catalog change has touched, so a disabled sweep leaves repository webhooks un-reasserted and commit and pull-request state unrefreshed on a settled installation. Leave the interval at its `15m` default, or set another positive value, if you had turned the sweep off.

### Security

* The Lunar images upgrade Go dependencies carrying advisories flagged by software-composition analysis: `golang.org/x/crypto` to v0.55.0, resolving the critical CVE-2026-56854, and, for high-severity advisories, `google.golang.org/grpc` to v1.82.1 (GHSA-hrxh-6v49-42gf, xDS RBAC and HTTP/2), `github.com/labstack/echo/v4` to v4.15.3 (CVE-2026-55677), and `golang.org/x/net` to v0.57.0 (CVE-2026-46600); `go.opentelemetry.io/otel` moves to v1.44.0, `github.com/klauspost/compress` to v1.18.7, `golang.org/x/sys` to v0.47.0, `golang.org/x/term` to v0.45.0, and `golang.org/x/text` to v0.41.0 as part of the same upgrades. Upgrading the chart pulls the patched images; behavior is unchanged.

### Features

#### Chart-managed password for the SQL API database role <a href="#self-hosted-sqlapi-user-password-managed-by-chart" id="self-hosted-sqlapi-user-password-managed-by-chart"></a>

The chart now supplies the password for `sqlapi_user`, the read-only Postgres role behind the connection string `lunar sql connection-string` vends, through the new `hub.db.sqlapiPassword` value. `mode: generate`, the default, creates a random 32-character alphanumeric password and keeps it across upgrades, so a connection string already handed to a BI tool or a saved `psql` invocation keeps working; the character set is deliberate, because the Hub builds the connection string without percent-encoding and a symbol would produce a URL clients cannot parse. `mode: secret` reads the password from a Secret you manage, named by `secretName` and `passwordKey`. `mode: unmanaged` renders no environment variable at all, for an install that pre-created the role and owns its credential.

There was no chart value for this before, and setting nothing did not produce an empty password: a fresh database got a role with no usable password verifier, unable to authenticate by any means, while the Hub went on vending a connection string with an empty password. Only databases created without a password were affected — an already-migrated database keeps the password it was first created with.

Such an install repairs itself on upgrade. The SQL API code package is re-applied in full on every migrate run, so the role's password is set to the value the chart supplies; there is no manual `ALTER ROLE` to run and nothing to clean up. With `mode: unmanaged`, a role you pre-created keeps the password you gave it. Under GitOps tooling that renders without reading the cluster — Argo CD, or `helm template` — `generate` cannot read an existing generated Secret back, so use `mode: secret` there, as with `hub.auth.secretName`.

* The Hub's repository reconciliation concurrency is now configurable with `HUB_MAX_WORKERS_REPO_SYNC`, set through `hub.extraEnv`; it caps concurrent repository sync jobs per Hub replica, defaults to `5` — the previously hard-coded value, so an upgrade changes nothing — and `0` means unlimited, matching the other worker caps. A value below zero is rejected at startup. Lowering the cap spreads GitHub and GitLab API traffic over more time but does not reduce the total number of requests reconciliation makes, so watch the `repo_sync` queue backlog and its oldest available job age after lowering it: sustained growth means the cap is below the incoming work rate.

### Improvements

* An installation that lengthens the repository reconcile window with `HUB_GITHUB_SYNC_RECONCILE_WINDOW` now gets a startup warning when `HUB_GITHUB_ETAG_CACHE_TTL` (12 hours by default) is not longer than that window plus its 50% per-repository jitter, because cached GitHub responses then expire before a repository is next revisited and never reach the revalidation that would renew them. Raise the TTL above the reconcile window plus jitter; it bounds shared retention only, since each Hub replica keeps its in-process copy for at most 12 hours regardless. See [GitHub conditional-request cache renewal](/release-notes/product/2026#product-2026-09-02).
* The Hub now reports how much of its GitHub REST budget each kind of work consumes: every request that reaches GitHub is counted and timed, published as `lunar.github.requests` and `lunar.github.request_duration` over OTLP and as `lunar_github_requests_total` and `lunar_github_request_duration_seconds` on the `/metrics` scrape endpoint, alongside the existing per-installation rate-limit gauges that show how much budget remains. Both carry three fixed labels only — `operation` (the endpoint family, such as `pr_files`, `repo_commits`, `check_runs`, `hooks`, or `issue_comments`), `source` (`pull_request`, `workflow_run`, `repo_sync`, `webhook_heal`, or `other`), and `outcome` (`success`, `not_modified`, `rate_limited`, or `error`) — deliberately without owner, repository, pull-request, commit, URL, or delivery-ID labels, so cardinality stays bounded. Retries and pagination are counted as separate attempts because each one spends from the budget, and a conditional request that GitHub answers with 304 is recorded as `not_modified` even though the cache replays it as a 200. The Hub also logs one line per configured GitHub App pool at startup, giving the host, owner, and number of Apps in that pool, so available pool capacity can be confirmed without exposing credentials or installation IDs.
* Data retention now ages out the cataloger delta rows in `hub.catalog_json_items` that links from the retired catalog snapshots used to pin in place. On an installation carrying data from before scoped catalog materialization those links held most aged delta rows out of every sweep — two thirds of the aged rows where this was measured — so the rows were never reclaimed however old they got. With the snapshot tables gone, an aged delta row is kept only while a live catalog contribution still references it, and is deleted otherwise.
* The sidecar container in a script pod now observes pod status through one shared Kubernetes watch for the whole batch instead of one watch per script container, so its watch load and in-process state no longer grow with batch size — the suspected cause of sidecar OOMKills and of bursts of `410 Gone / too old resource version` warnings on large batches. The sidecar also logs an observation summary while a batch runs and when it completes, reporting pod status events processed, watch reconnects, watch relists, peak goroutines, and, where the container's cgroup exposes it, peak memory in bytes, so a sidecar restart can be attributed without extra instrumentation. No configuration change is required; installs that raised the sidecar's memory limit can keep their current value. See [Script runs survive an interrupted Kubernetes pod watch](/release-notes/product/2026#product-2026-09-02).

## 2026-08-25 <a href="#self-hosted-2026-08-25-1" id="self-hosted-2026-08-25-1"></a>

**Helm chart version:** `3.18.0`\
**Lunar images version:** `3.18.0`\
**Product updates included:** [2026-08-25](/release-notes/product/2026#product-2026-08-25)

### Product updates

* **Breaking change:** [Canonical `pr_status` values in the SQL API](/release-notes/product/2026#product-2026-08-25)
* **Breaking change:** [A wildcard in a component's `branch` is now rejected](/release-notes/product/2026#product-2026-08-25)
* **Feature:** [Multiple GitHub Apps for one organization](/release-notes/product/2026#product-multiple-github-apps-per-owner)
* **Feature:** [Custom failure text per policy](/release-notes/product/2026#product-policy-failure-text-templates)
* **Feature:** [Customizable `lunar policy ok-release` verdict output](/release-notes/product/2026#product-2026-08-25)
* **Feature:** [Queryable policy annotations with `meta`](/release-notes/product/2026#product-2026-08-25)
* **Feature:** [Definition links from dashboards to a script's declaration](/release-notes/product/2026#product-2026-08-25)
* **Improvement:** [Component score gauges](/release-notes/product/2026#product-2026-08-25)
* **Improvement:** [Dashboard status icons](/release-notes/product/2026#product-2026-08-25)
* **Improvement:** [Faster report refresh after a gate bypass](/release-notes/product/2026#product-2026-08-25)
* **Improvement:** [Full script descriptions on the collector and policy dashboards](/release-notes/product/2026#product-2026-08-25)
* **Improvement:** [Shorter hold for nearly empty SQL API materialization batches](/release-notes/product/2026#product-2026-08-25)
* **Improvement:** [Faster Policies listing dashboard](/release-notes/product/2026#product-2026-08-25)
* **Improvement:** [Score policies run on pull requests](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Bypass commands written as Markdown code](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Bypass reply templates that read back as a command](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Collection follows the branch each component tracks](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Collector reruns reach components tracking a non-default branch](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Component JSON reflects data collected against a commit that already had data](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Configuration pull with `--rerun-catalogers` no longer hangs on multi-replica Hubs](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Domain scorecard Badge and Checks columns](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [A stalled GitLab API call no longer consumes a whole webhook delivery](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Scheduled collectors run on open GitLab merge requests](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Latest state for components pinned to a non-default branch](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Merged pull requests keep their run history](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Out-of-band collect no longer discards a component's earlier collected data](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Repository sync covers every branch a component tracks](/release-notes/product/2026#product-2026-08-25)
* **Bug fix:** [Workflow runs reported on pull-request refs](/release-notes/product/2026#product-2026-08-25)

### Migrations and upgrade notes

* Drop any `HUB_RETENTION_*` entries from `hub.extraEnv` when you adopt `hub.retention`. `extraEnv` renders after the retention block, so keeping both emits the same variable names twice in the Hub container: a plain `helm upgrade` is last-wins and survives it — your `extraEnv` value still takes effect and the API server only warns that the earlier definition is hidden — but server-side apply rejects the object outright with `duplicate entries for key [name="HUB_RETENTION_ENABLED"]`, so a GitOps install running `ServerSideApply=true` fails its next sync rather than degrading quietly. Installs that never set these through `extraEnv` are unaffected.
* Database migrations now bound how long they wait for a table lock instead of queuing ahead of live traffic and blocking every new reader and writer of the table while they wait: the connection that applies them runs with `lock_timeout = '5s'`, and the schema migrations that rework foreign keys on the Hub's largest tables — `policy_runs`, `collection_records`, and `catalog_delta_contributions` — allow 30 seconds. A contended migration therefore fails fast rather than stalling the upgrade or being killed as a deadlock victim minutes in: on a busy installation an attempt of the pre-rollout `lunar-hub-migrate` Job can end with `canceling statement due to lock timeout` (SQLSTATE `55P03`) in its logs, the Job is retried according to its `backoffLimit`, and the upgrade proceeds once a lock is granted. Index builds are unaffected and still wait as long as they need.
* Upgrading runs a database migration that normalizes stored pull-request states in place, folding GitLab's `opened` and `locked` values onto `open`; no operator action is required, and the migration writes nothing on a GitHub-only installation. On an installation connected to GitLab, expect a step change in scheduled collection volume — and in the script-runner capacity it consumes — on the first deploy, because cron collectors that declare `runs_on: [prs]` begin running against open merge requests. See [Scheduled collectors run on open GitLab merge requests](/release-notes/product/2026#product-2026-08-25).
* An installation that already sets a `webhook_secret` on an entry in `HUB_GITHUB_APPS` has existing repository hooks carrying the operator-level `HUB_GITHUB_WEBHOOK_SECRET`, which is no longer accepted for those owners after this upgrade: each affected repository's next delivery is rejected, Lunar re-stamps that repository's hook with the per-App secret, and its later deliveries succeed, so the fleet converges repository by repository as each one next sees activity. Redeliver anything lost in that gap from Settings → Webhooks → Recent Deliveries in GitHub, within GitHub's three-day window, and leave `HUB_WEBHOOK_HEAL_MIN_INTERVAL` at a non-zero value, since setting it to `0` turns the automatic repair off and the hooks then have to be deleted and recreated by hand. Installations that configure no per-App secrets need no action.
* This upgrade runs database migrations that build the indexes retention needs on the commit, pull-request, repository, and configuration reference columns, and that re-declare two foreign keys so a delete in one retention tier cannot cascade into another. The indexes are built concurrently and the constraints are validated without an exclusive lock, so reads and writes continue throughout, but on a large database the migration job takes noticeably longer than usual to finish — allow for that before rolling the Hub.
* This upgrade adds the indexes the retention sweep needs on the run-history tables: BRIN indexes on the timestamp column each table ages on, and btree indexes on four foreign-key columns pointing into them that were previously unindexed. They are built concurrently and never block reads or writes, but the migration job waits for the builds to finish, so on a large database this upgrade can take noticeably longer than a usual one.

### Security

#### Per-App GitHub webhook secrets now apply to repository hooks <a href="#self-hosted-per-app-github-webhook-secrets-on-repository-hooks" id="self-hosted-per-app-github-webhook-secrets-on-repository-hooks"></a>

A `webhook_secret` set on an entry in `HUB_GITHUB_APPS` is now stamped on the repository webhooks Lunar creates for that App's owners, and repository deliveries from those owners are verified against it.

Previously the per-App value was consulted only for App-level deliveries, which Lunar never creates, so every repository hook carried the operator-level `HUB_GITHUB_WEBHOOK_SECRET` whichever App owned the repository. Two Apps configured with distinct secrets were therefore not isolated from one another: a secret leaked from one App validated deliveries forged for the other, which is the isolation the setting is documented to provide.

The secret is selected by the repository's owner and host, so the same owner name on github.com and on a GitHub Enterprise Server host do not share a secret. An owner whose App sets no per-App secret continues to use the operator-level `HUB_GITHUB_WEBHOOK_SECRET`, so an installation that configures no per-App secrets behaves exactly as before.

### Features

#### Retention for superseded configuration and Git history <a href="#self-hosted-config-and-git-history-retention-tier" id="self-hosted-config-and-git-history-retention-tier"></a>

A second, opt-in retention tier removes what the runs window never reached: superseded Lunar configuration generations together with the components, domains, and script definitions published under them, plus commits, pull requests, and repositories. Set `HUB_RETENTION_CASCADE_ENABLED=true` alongside `HUB_RETENTION_ENABLED` — it defaults to `false` and stays off through an upgrade — and the Hub's periodic retention sweep prunes a configuration generation once it is both superseded and older than `HUB_RETENTION_CONFIG`, which defaults to `90d`; `0` keeps every superseded generation. The newest generation for a repository is never pruned at any age, and no setting overrides that, so a repository always keeps the configuration its collectors and policies run against. A generation whose runs have not aged out yet is skipped and picked up on a later pass.

The same switch ages out commits on when they were committed and pull requests on when they were opened, against the `HUB_RETENTION_RUNS` window, and removes a repository only once nothing in the database still references it — its age acts only as a floor, so an active repository is never removed. Before a commit or repository is deleted, every table that names it is checked, including the many that name it by SHA or by identifier without a foreign key, so pruning cannot silently orphan data such as a component's current head commit.

This tier is kept behind its own switch because it deletes definitions rather than history, and it is the only part of retention that deletes a component.

#### Automatic repair after a GitHub webhook secret rotation <a href="#self-hosted-github-webhook-secret-self-heal" id="self-hosted-github-webhook-secret-self-heal"></a>

Rotating the GitHub webhook secret no longer leaves every already-installed hook signing with the old value. A hook keeps the secret it was created with, so after a rotation its deliveries fail signature validation and are rejected; the only remedy was deleting Lunar's hook on each repository so it would be recreated. The Hub now recognizes a delivery whose signature is well formed but does not verify, matches it to the hook it installed for that repository, and writes the currently configured secret onto that hook, so the next delivery succeeds.

Repairs run one at a time, each repository on its own next delivery, spaced by `HUB_WEBHOOK_HEAL_MIN_INTERVAL` — one second by default, so a fleet-wide rotation is not a burst of writes against GitHub's rate limits and a 30,000-repository install converges in roughly eight hours. Treat that rate as a floor: the Hub backs off whenever GitHub reports a rate limit, an allowance shared with the commit statuses and comments Lunar posts, so rotate a large fleet outside busy hours rather than shortening the interval. Setting the interval to `0` disables self-healing and leaves the manual delete-and-recreate procedure.

The delivery that reports the problem is still rejected, and GitHub does not retry it — replay it from the repository's Recent Deliveries within GitHub's three-day window. A repository with no activity stays on the old secret until something is pushed to it. GitLab is unchanged: its hooks carry no identifier that lets a failed delivery be attributed, so a rotated GitLab webhook secret still requires deleting each project's hook.

#### Data retention configured from chart values <a href="#self-hosted-hub-retention-chart-values" id="self-hosted-hub-retention-chart-values"></a>

Hub data retention is now configured through `hub.retention` chart values — `enabled`, `window`, `configWindow`, `cascadeEnabled` and `vacuumEnabled`, plus the pacing keys `interval`, `batchSize`, `maxBatchesPerRun` and `vacuumLockTimeout` — instead of raw `HUB_RETENTION_*` entries in `hub.extraEnv`.

Retention is off by default. An install that sets none of these keeps run history forever, exactly as before the block existed, so upgrading changes nothing on its own.

`window` sets `HUB_RETENTION_RUNS` and `HUB_RETENTION_DERIVED` to the same value from one key, deliberately: the Hub refuses to boot when the two differ, because derived surfaces rebuilt from the run tables would otherwise truncate themselves back to the runs window within a day.

Write windows in days. `window` and `configWindow` take Go durations extended with `d` (24h) and `w` (7d), so `m` is still Go's minutes — `12m` is a twelve-minute window that passes every validation and deletes essentially all run history on the first sweep, and twelve months is `365d`. The extension covers only those two keys: `interval` and `vacuumLockTimeout` are plain Go durations, so a daily sweep is `24h` and `interval: 1d` renders happily but leaves the Hub crashlooping on `unknown unit "d"` after the migrate Job has already run.

`cascadeEnabled` and `vacuumEnabled` are separate switches because they delete more than run rows. `vacuumEnabled` is not gated by `enabled`: the compaction pass has its own queue and flag and never consults the master switch, so `enabled: false` with `vacuumEnabled: true` still compacts nightly. The three pacing keys are validated at boot even when `enabled` is false, and none may be zero.

#### Reclaiming deleted space with nightly compaction <a href="#self-hosted-retention-disk-compaction" id="self-hosted-retention-disk-compaction"></a>

Deleting run history caps database growth but does not shrink the files on disk — the freed space stays allocated to the table. The Hub can now hand it back to the operating system: set `HUB_RETENTION_VACUUM_ENABLED=true` and a nightly pass, scheduled by `HUB_RETENTION_VACUUM_SCHEDULE` (default `0 3 * * *`), compacts at most one run-history table per night, worst first. Only the tables data retention deletes from are candidates; the queue tables are never compacted.

The schedule says when the pass looks, not when it acts. A table is compacted only when its estimated reclaimable space clears both `HUB_RETENTION_VACUUM_MIN_BYTES` (default 1 GiB) and `HUB_RETENTION_VACUUM_MIN_RATIO` (default `0.2`, a fifth of the table), so on most nights it does nothing. Expect it to matter once, after retention first drains a large backlog.

Compaction holds an exclusive lock on the table it rewrites: queries against that table block for the duration and then complete, so choose a quiet window. `HUB_RETENTION_VACUUM_LOCK_TIMEOUT` (default `5s`) bounds how long the pass waits for that lock before giving up until the next night, and `HUB_RETENTION_VACUUM_TIMEOUT` (default `2h`) caps a single compaction. Neither accepts zero — the Hub refuses to start, because to Postgres a zero `lock_timeout` means waiting forever, which is how a bounded stall becomes an unbounded one.

Compaction is off by default, so an upgrade changes nothing until an operator turns it on.

#### Run-history retention <a href="#self-hosted-run-history-retention" id="self-hosted-run-history-retention"></a>

A Hub install can now bound the growth of its run history. Set `HUB_RETENTION_ENABLED=true` and the Hub periodically deletes script runs, policy runs, policy assertions and rollups, collection records, and catalog JSON items older than `HUB_RETENTION_RUNS` (default `90d`). Retention is off by default and stays off through an upgrade, so nothing is removed until an operator opts in; on Kubernetes, set it through the `hub.retention` chart values described in [Data retention configured from chart values](#self-hosted-hub-retention-chart-values).

Rows that something still references are kept whatever their age — a catalog JSON item still linked into the current catalog, for example — and are reconsidered on a later sweep once their dependants have aged out themselves.

Each sweep is rate limited, so turning retention on for the first time on an established install drains the accumulated backlog over days instead of in one pass: `HUB_RETENTION_BATCH_SIZE` (5000) and `HUB_RETENTION_MAX_BATCHES_PER_RUN` (100) bound the work of a single sweep, and `HUB_RETENTION_INTERVAL` (1h) sets how often it runs. The Hub logs what each sweep deleted, per table, along with `budget_exhausted` — while that is true there is still a backlog to work through, and when it turns false the install has caught up.

Deleting rows caps growth but does not return space to the operating system; the freed space becomes reusable by the same tables.

* `HUB_GITHUB_APPS` and `hub.github.apps` now accept more than one App with the same owner. Give each entry its own App ID, installation ID, and private key; on Kubernetes, use `privateKeyFile` to select the matching key from `appsSecret`. See [Multiple GitHub Apps for one organization](/release-notes/product/2026#product-multiple-github-apps-per-owner) and [Avoid GitHub rate limiting](/install/git-platforms/github#avoid-github-rate-limiting).
* New `operator.scriptPodTopologySpreadConstraints` chart value sets `topologySpreadConstraints` on every script pod the Operator creates, rendered as the JSON-encoded `OPERATOR_SNIPPET_POD_TOPOLOGY_SPREAD_CONSTRAINTS` environment variable, so script pods can be spread across zones, nodes, or subnets instead of piling onto whichever node the scheduler picks — the usual mitigation for lopsided per-subnet IP exhaustion. A constraint that omits `labelSelector` is filled in with the Operator's own `app.kubernetes.io/managed-by: lunar-snippet-operator` selector, because Kubernetes treats an absent selector as matching no pods, which would make the constraint a silent no-op; an explicit selector is left alone, so narrowing to one script type with `lunar.earthly.dev/snippet-type` still works. `whenUnsatisfiable: ScheduleAnyway` is the recommended setting: these pods are ephemeral and single-shot, and `DoNotSchedule` on a skewed cluster leaves them Pending until the pending-pod timeout and drops throughput. `maxSkew`, `topologyKey`, and `whenUnsatisfiable` are validated when the Operator starts — an invalid value fails startup, naming the offending constraint by index, rather than failing every pod creation later. Constraints are applied globally to all script pods; `minDomains` and whether the spread is actually satisfiable are not validated.

### Improvements

* `hub.db.connectionOptions` is now a map of Postgres connection parameters, and the chart renders the syntax each consumer expects, so the separator is no longer yours to get right; the default remains `sslmode: require`, and overriding replaces the whole map rather than merging into it. A new `hub.db.sqlapiConnectionOptions` sets the options the Hub hands to SQL API clients, surfaced by `lunar sql` — empty, the default, inherits `hub.db.connectionOptions`, so nothing about an existing install changes. Set it when SQL API clients reach Postgres by a different route than the Hub does, for example through a connection pooler on a hostname of its own that clients should verify with `sslmode: verify-full` and `sslrootcert: system` (which needs libpq 16 or newer; older clients need an explicit CA path). Keys render in sorted order rather than the order written, so migrating an existing multi-option string may reorder it, which nothing reads positionally.
* An entry in `hub.github.apps` can now name the data key in `appsSecret` holding its PEM, with the optional `privateKeyFile`; it defaults to `<lowercase-owner>.pem`, so existing values render byte-for-byte unchanged. Set it to give one owner more than one GitHub App: GitHub's REST rate limit applies per App installation, so a second App on a busy org gives the Hub a second, independent budget, and two entries with the same owner would otherwise derive the same PEM filename and share a key. Requires Lunar Hub 3.14.0 or newer, which pools an owner's Apps and spreads read traffic across them while keeping commit statuses and pull-request comments on the owner's first entry; earlier Hubs reject a repeated owner at boot.
* The Hub now reports the GitHub REST rate-limit budget of each GitHub App installation it uses, as the gauges `lunar_github_rate_limit_remaining` and `lunar_github_rate_limit_limit` on its `/metrics` scrape endpoint and over OTLP, so an exhausted budget is visible before the 403 responses it causes. Each sample is read from the rate-limit headers GitHub already returns on responses the Hub was making anyway — no extra API calls — and is labelled with the GitHub host, owner, App ID, and rate-limit resource only, deliberately without a per-repository label.
* The Hub now prunes the Lunar configuration install directories it writes to its state volume after 10 days by default: `HUB_INSTALL_FILE_MAX_AGE_DAYS` now defaults to `10`, matching the CLI's equivalent, instead of `0`, which disabled age-based pruning entirely and left every generation on disk for the life of the install. Size-based pruning remains off, since `HUB_INSTALL_FILE_MAX_DISK_SIZE` still defaults to `0`; set it if the volume is small enough that age alone could still fill it. To keep the previous behavior of never pruning by age, set `HUB_INSTALL_FILE_MAX_AGE_DAYS` to `0`.
* The `HUB_RETENTION_RUNS` window now also covers merged collection blobs — the merged component data stored per commit and component — aged on when the newest collection record folded into them arrived, so they no longer outlive the collection records they were built from.
* The migrate Job may now retry 10 times before the release fails, up from a hardcoded 2, and the limit is settable as `hub.migrateJobBackoffLimit`. The retry exists for lock contention: changing a foreign key takes `ACCESS EXCLUSIVE` on both tables involved, so against a database still serving traffic a migration can lose a lock race, and the next attempt usually gets the lock. Ten is a ceiling rather than what an upgrade spends — Kubernetes backs off exponentially between attempts (10s, doubling, capped at 6m), landing them at roughly t = 0, 12, 32, 72, 152 and 312 seconds, so a default `helm upgrade` with its 5-minute timeout gets five attempts and raising `--timeout` buys the tail. A migration that is genuinely broken rather than unlucky still fails on the first attempt and every one after.
* The `lunar-hub-migrate` pre-rollout Job now logs a line as it starts each migration, naming the migration file, its sequence, the direction, and the version table it belongs to, so an upgrade that is sitting inside one long-running statement can be told apart from one blocked on a lock. The queue-library migrations, which previously applied without any log output, are now logged on the same handler and level as the rest.
* A database migration sets per-table autovacuum settings on the Hub's largest and most churn-heavy tables — script runs, policy runs, policy run rollups, the latest-policy-run table, policy assertions, collection records, and catalog JSON items — lowering their vacuum and analyze scale factors to 1% of the table with a 10,000-row floor. Until now these tables inherited the cluster defaults, whose proportional trigger meant the biggest tables were vacuumed least often and accumulated millions of dead tuples between passes; they are now vacuumed and analyzed more frequently and in smaller passes, which also keeps planner statistics closer to the data. The change is a catalog update that blocks neither reads nor writes and applies instantly regardless of table size; it runs automatically during the upgrade and requires no operator action. Installations that tune autovacuum for these tables at the cluster level should note that the per-table settings now take precedence.
* Retention windows are now written in days: `HUB_RETENTION_RUNS`, `HUB_RETENTION_DERIVED`, `HUB_RETENTION_CONFIG`, and `HUB_CATALOG_VERSION_RETENTION` all default to `90d`, and the Hub reports them back in the same units in logs and boot errors. Go durations such as `2160h` still parse, so existing settings keep working, and a negative window is now rejected at startup instead of producing a cutoff in the future.
* The Postgres coordinates the Hub hands out for the SQL API can now be overridden with the `HUB_SQLAPI_HOST` and `HUB_SQLAPI_PORT` environment variables, so `lunar sql connection-string` can point at an address the client actually reaches — a pooler or proxy in front of the database — rather than the host the Hub itself dials. Left unset, both fall back to the Hub's own database host and port, so an existing install vends the same connection string as before.
* Session bounds can now be applied to the read-only `sqlapi_user` role: set `HUB_SQLAPI_STATEMENT_TIMEOUT` and `HUB_SQLAPI_IDLE_IN_TRANSACTION_TIMEOUT` (for example `60s` and `120s`) through `hub.extraEnv`, which the chart's `hub-migrate` Job inherits, since the migration is what applies them. Both are unset by default and unset means no bound, so an install that does not opt in keeps today's behavior; because the migration owns these role settings, clearing an environment variable removes the bound again, and a value set on the role by hand is cleared on the next migrate run. Setting a bound requires the Hub's database role to be able to `ALTER ROLE` — on a reduced-privilege install where `sqlapi_user` was pre-created and `CREATEROLE` was not granted, asking for a timeout fails the migrate Job rather than silently doing nothing. These bounds guard against accidental long-running or abandoned sessions only; a client can raise them again from its own session.

### Bug fixes

* `hub.github.apps` entries are now unique by host, owner and App ID instead of by owner alone, so the same owner name on github.com and on a GitHub Enterprise Server host is accepted — a combination the Hub has always supported and the chart previously rejected. Paired with that relaxation, two different Apps that resolve to the same PEM filename now fail template rendering with an error naming the file and pointing at `privateKeyFile`, instead of the second App signing its JWT with the first App's key. One App installed across several orgs still shares a key, which is legitimate.
* A Hub pod now keeps serving against a database whose schema is ahead of its own binary, so a rolling upgrade no longer disrupts the not-yet-replaced pods. Because the pre-rollout `lunar-hub-migrate` Job completes before any new pod starts, a release that added a column left every old pod unable to read the affected tables — surfacing as errors such as `failed to load current manifest: missing destination name …` and taking down Lunar configuration loading, webhook ingestion, out-of-band collection, and scheduled `cron` collection for the length of the rollout, and indefinitely after a rollback to an older Hub image. The Hub's queries now name the columns they read instead of selecting every column, so an added column is ignored by an older binary.
* A database migration that builds an index concurrently now drops any existing copy of that index first, so an upgrade whose migration Job is interrupted — by its deadline expiring or the pod being evicted mid-build — can simply be retried: the retry rebuilds the index instead of matching the invalid leftover, skipping the build, and recording the migration as applied over an index the planner will never use. An index already left invalid by an interrupted upgrade on an earlier version is not repaired by this change, because those migrations do not run again; such an index has to be dropped and re-created by hand.
* The pre-install/pre-upgrade migrate Job now runs for up to 3600 seconds before Kubernetes kills it, up from a hardcoded 600, and the deadline is settable as `hub.migrateJobActiveDeadlineSeconds`. Ten minutes was sized as though it were a budget for how long migrations may take, so it fired on migrations that were healthy but slow — an index built with `CREATE INDEX CONCURRENTLY` scales with table size, and a 17 GB install spent roughly eight minutes inside a single concurrent build on a 2.1M-row table and failed the hook with `DeadlineExceeded`. The deadline is a backstop that reaps a wedged Job, needed because the migrator's advisory lock waits forever; how long an upgrade actually waits is your Helm timeout, so keep this value comfortably above it.
* The nightly index rebuild on the Hub's and script Operator's internal job-queue tables now gets 15 minutes per index instead of the queue library's one-minute default, which a busy queue could not meet: `REINDEX INDEX CONCURRENTLY` waits out in-flight transactions before and after the build, so any transaction outliving the timeout aborted the rebuild. An aborted rebuild left an invalid `<index>_ccnew` index behind that Postgres kept maintaining on every job insert while the planner could not use it, and every later run then skipped that index permanently, so job-queue index bloat was never reclaimed. The timeout is configurable through `HUB_QUEUE_REINDEXER_TIMEOUT` and `OPERATOR_REINDEXER_TIMEOUT`. An installation that has already accumulated invalid `_ccnew` indexes needs a one-off cleanup after upgrading — confirm the original index each one shadows is still valid and ready, then `DROP INDEX CONCURRENTLY` the artifact — because the rebuild keeps skipping any index whose artifact is still present.

### Deprecations

* A plain string such as `sslmode=require` is still accepted for `hub.db.connectionOptions` and `hub.db.sqlapiConnectionOptions` and reaches every consumer verbatim, so an install upgrading from an earlier chart renders unchanged, but strings are removed in chart 4.0.0 — switch these values to maps. While you pass one, Helm logs `warning: cannot overwrite table with non table`, which is informational and does not stop the string being applied, and a string carrying more than one option is wrong for some consumers, since they do not all separate options the same way; the install notes flag it at upgrade time.

## 2026-08-19 <a href="#self-hosted-2026-08-19-1" id="self-hosted-2026-08-19-1"></a>

**Helm chart version:** `3.13.2`\
**Lunar images version:** `3.13.2`\
**Product updates included:** [2026-08-19](/release-notes/product/2026#product-2026-08-19)

### Product updates

* **Breaking change:** [Case-variant duplicate component names rejected](/release-notes/product/2026#product-2026-08-19)
* **Bug fix:** [Checks no longer stuck pending when no `after-json` hook matches](/release-notes/product/2026#product-2026-08-19)
* **Bug fix:** [Case-insensitive repository identity matching](/release-notes/product/2026#product-2026-08-19)

## 2026-08-18-2 <a href="#self-hosted-2026-08-18-2" id="self-hosted-2026-08-18-2"></a>

**Helm chart version:** `3.13.1`\
**Lunar images version:** `3.13.1`\
**Product updates included:** [2026-08-18](/release-notes/product/2026#product-2026-08-18)

### Product updates

* **Improvement:** [Policy-qualified check names in the checks report](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Runs listing refresh no longer reads whole run histories](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Consistent check naming and ordering in the checks report](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Concurrent cataloger runs no longer silently lose results](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [GitLab release badge honors release-gate bypasses](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Multi-line failure messages render as nested lists in checks reports](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Runs listing refresh no longer degrades after long refresh passes](/release-notes/product/2026#product-2026-08-18)

## 2026-08-18-1 <a href="#self-hosted-2026-08-18-1" id="self-hosted-2026-08-18-1"></a>

**Helm chart version:** `3.13.0`\
**Lunar images version:** `3.13.0`\
**Product updates included:** [2026-08-18](/release-notes/product/2026#product-2026-08-18)

### Product updates

* **Feature:** [Customizable bypass revocation reply](/release-notes/product/2026#product-2026-08-18)
* **Feature:** [GitHub pull-request comment break-glass bypass](/release-notes/product/2026#product-2026-08-18)
* **Feature:** [Withdraw a GitLab merge-gate bypass with /lunar bypass rm](/release-notes/product/2026#product-2026-08-18)
* **Feature:** [Quiet pull-request comments until first failure](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Blocking-checks counts scoped to merge-gating checks](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Bypass hint in the failing required checks report](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Faster checks data for components with long configuration histories](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Faster SQL API checks refresh](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Concurrent SQL API materialization](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Bypasses visible on the component and pull-request dashboards](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Bypass hints on dashboard gate banners](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [GitLab comment bypasses work below Ultimate](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [History tab rename](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Runs dashboard refresh: indexed active-component enumeration](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Faster scoped SQL API materialization refreshes](/release-notes/product/2026#product-2026-08-18)
* **Improvement:** [Batched SQL API materialization](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Failed bypass commands now reply in the thread](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Granted bypasses refresh the results comment](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Component JSON no longer nondeterministically hides collected data after a configuration update](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Cron collectors keep every write](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Deleted or inaccessible repository sync fix](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [GitHub PR check honors bypasses](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Failed GitHub webhook deliveries are now reported as failures](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [GitLab merge gate answered when a collection resolves no components](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [GitLab merge gate answered for components with no policies](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [GitLab webhook processing no longer lost on delivery timeout](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [GitLab webhook repair after a Hub URL change](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Global catalog panel no longer shows a frozen catalog](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Home dashboard configuration link resolves for GitLab and GitHub Enterprise Server](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Manifest panel repository column truncation fix](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Pull-request scorecard timeout fix](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Runs listing refresh no longer falls behind on large installations](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Runs listing refresh no longer starved by very large components](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Dashboards detect a component's forge from its host](/release-notes/product/2026#product-2026-08-18)
* **Bug fix:** [Stale webhook cleanup](/release-notes/product/2026#product-2026-08-18)

### Migrations and upgrade notes

* This release upgrades the Hub's internal job-queue library, and the pre-rollout `lunar-hub-migrate` Job applies its schema migration during the upgrade: it adds columns and indexes to the internal `river_job` table and backfills existing rows while holding locks on that table. The migration measured about 1.2 seconds per 400,000 job rows, so on an installation with a very large job backlog expect the migrate Job — which gates the rollout — to take correspondingly longer.

### Improvements

* Hub startup is much lighter on the database on large installations: the boot-time maintenance passes that keep derived check-run data consistent now resume from the last point they covered instead of re-scanning the entire run history on every boot of every replica — previously around fourteen minutes of full-table scanning per start on a large installation. The passes still run on every boot and still correct whatever they find; only the scan range is narrowed, and any interrupted or failed pass falls back to scanning more, never less.
* A database migration drops five unused indexes from the table recording script runs (`hub.snippet_runs`), the most write-heavy table in the database, reducing its index storage — about 8 GB reclaimed on a large installation — and the write overhead of maintaining them. The migration runs automatically during the upgrade; no operator action is required.

## 2026-08-13 <a href="#self-hosted-2026-08-13-1" id="self-hosted-2026-08-13-1"></a>

**Helm chart version:** `3.12.0`\
**Lunar images version:** `3.12.0`\
**Product updates included:** [2026-08-13](/release-notes/product/2026#product-2026-08-13)

### Product updates

* **Feature:** [Customizable checks report templates](/release-notes/product/2026#product-checks-report-templates)
* **Improvement:** [Refreshed built-in checks report](/release-notes/product/2026#product-2026-08-13)
* **Improvement:** [Release-gate banner on component details](/release-notes/product/2026#product-2026-08-13)
* **Improvement:** [Deployment history tab](/release-notes/product/2026#product-2026-08-13)
* **Improvement:** [Consistent scorecard status icons and columns](/release-notes/product/2026#product-2026-08-13)
* **Bug fix:** [Leftover blank check rows removed](/release-notes/product/2026#product-2026-08-13)
* **Bug fix:** [Import-site runs\_on honored for uses imports](/release-notes/product/2026#product-2026-08-13)

### Security

* The Lunar images upgrade the `golang.org/x/text` dependency to v0.39.0, resolving the high-severity CVE-2026-56852 flagged by software-composition analysis; `golang.org/x/sync` moves to v0.21.0 as part of the same upgrade.

## 2026-08-12 <a href="#self-hosted-2026-08-12-1" id="self-hosted-2026-08-12-1"></a>

**Helm chart version:** `3.11.0`\
**Lunar images version:** `3.11.0`\
**Product updates included:** [2026-08-12](/release-notes/product/2026#product-2026-08-12)

### Product updates

* **Feature:** [Catalog history in the SQL API](/release-notes/product/2026#product-2026-08-12)
* **Improvement:** [Initiatives dashboard performance](/release-notes/product/2026#product-2026-08-12)

### Features

* The catalog versions backing the SQL API `catalog` view are pruned after a retention window set by the Hub's `HUB_CATALOG_VERSION_RETENTION` environment variable (default `2160h`, 90 days; `0` disables pruning). The newest version is always kept regardless of age, so the view never goes empty. Each version stores a full catalog document — roughly 1 MB at 30,000 components — so the window bounds the history's disk usage. See [Catalog history in the SQL API](/release-notes/product/2026#product-2026-08-12).

### Improvements

* Posting Lunar's check to a GitHub pull request no longer holds a database advisory lock — and the pooled database connection it pins — across GitHub API calls when a check run already exists for the commit; the lock is now taken only when the check run is first created. Under load on a large test installation, that lock accounted for over half of all database time, with connections parked on GitHub latency, so heavily loaded installations should see database connection pressure drop.
* The Hub now logs a warning when a policy gate check arrives without a CI workflow run ID, naming the component, commit, and check type. Such a gate cannot exclude its own workflow run from the CI results it waits on and can block until its timeout when it shares a CI run with the component's collectors, so affected runs are now visible in the Hub logs instead of failing silently.

### Bug fixes

* The Hub no longer clones component repositories onto its own pod-local disk when dispatching code collectors or scheduled cron collector runs; the only checkout is the one made inside the script-runner pod, which was already the only one ever read. Previously these unused clones accumulated under `/var/tmp/lunar/collectors` on every Hub replica except the dispatching one — reaching tens of gigabytes on large installations, evicting Hub pods for exceeding ephemeral storage, and retaining repository source on Hub disks. One consequence for cron collectors: a branch that cannot be resolved now surfaces as a failed script run instead of being silently skipped with only a Hub log warning.

## 2026-08-11 <a href="#self-hosted-2026-08-11-1" id="self-hosted-2026-08-11-1"></a>

**Helm chart version:** `3.10.0`\
**Lunar images version:** `3.10.0`\
**Product updates included:** [2026-08-11](/release-notes/product/2026#product-2026-08-11)

### Product updates

* **Breaking change:** [`after-json` hooks now fire only when the path is present](/release-notes/product/2026#product-2026-08-11)
* **Breaking change:** [GitLab break-glass bypasses require the `/lunar bypass` slash command](/release-notes/product/2026#product-2026-08-11)
* **Feature:** [Custom bypass acknowledgement templates](/release-notes/product/2026#product-2026-08-11)
* **Feature:** [SQL API `catalog_latest` view](/release-notes/product/2026#product-2026-08-11)
* **Feature:** [The `missing-json` collector hook](/release-notes/product/2026#product-2026-08-11)
* **Improvement:** [Bypass audit rows recorded at merge and release](/release-notes/product/2026#product-2026-08-11)
* **Improvement:** [Faster Collectors listing](/release-notes/product/2026#product-2026-08-11)
* **Improvement:** [GitHub conditional-request caching on by default](/release-notes/product/2026#product-2026-08-11)
* **Improvement:** [Scoped catalog materialization by default](/release-notes/product/2026#product-2026-08-11)
* **Bug fix:** [Deterministic pull-request association for checks](/release-notes/product/2026#product-2026-08-11)
* **Bug fix:** [Stuck-run cleanup completes on large installations](/release-notes/product/2026#product-2026-08-11)

### Improvements

* Recoverable per-attempt failures during script log uploads are now logged as warnings, with only a terminal failure logged at error level, so transient upload retries no longer surface as error-level noise in log-based alerting.

## 2026-08-10 <a href="#self-hosted-2026-08-10-1" id="self-hosted-2026-08-10-1"></a>

**Helm chart version:** `3.9.1`\
**Lunar images version:** `3.9.0`\
**Product updates included:** None

### Features

#### Login-less Grafana viewing: `grafana.anonymousViewer` <a href="#self-hosted-grafana-anonymous-viewer" id="self-hosted-grafana-anonymous-viewer"></a>

The bundled Grafana can now be served with no login by setting `grafana.anonymousViewer: true` (default `false`). Unauthenticated visitors get the `Viewer` role in the default org and land straight on the dashboards — which is what makes the kiosk sidecar useful without handing out the admin password. The chart renders `GF_AUTH_ANONYMOUS_ENABLED` plus `GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer` on the Grafana Deployment; the role is fixed by the template rather than read from values, so it cannot be widened to Editor or Admin by a typo, and the admin login form stays enabled for real Editor and Admin access.

This applies to `grafana.mode: chart` only: `[auth.anonymous]` is a Grafana server setting read at boot, so the chart can only apply it to the pod it owns. Setting it under `external` or `off` mode fails the install instead of silently doing nothing.

Only enable this when Grafana is not reachable from the internet. A Grafana `Viewer` can issue arbitrary queries against every provisioned datasource — restricting that is a Grafana Enterprise feature — so anonymous viewing grants anyone who can reach the Service read access to the whole Lunar database through the read-only datasource.

## 2026-08-07 <a href="#self-hosted-2026-08-07-1" id="self-hosted-2026-08-07-1"></a>

**Helm chart version:** `3.9.0`\
**Lunar images version:** `3.9.0`\
**Product updates included:** [2026-08-07](/release-notes/product/2026#product-2026-08-07)

### Product updates

* **Breaking change:** [The `bypassed_checks` SQL view is append-only](/release-notes/product/2026#product-2026-08-07)
* **Improvement:** [GitLab comment bypasses on the shared bypass ledger](/release-notes/product/2026#product-2026-08-07)
* **Bug fix:** [Active PRs panel scoped to component paths](/release-notes/product/2026#product-2026-08-07)
* **Bug fix:** [GitLab merge-request status honors CLI bypasses](/release-notes/product/2026#product-2026-08-07)
* **Bug fix:** [GitLab merge-request numbers use the `!` sigil](/release-notes/product/2026#product-2026-08-07)
* **Bug fix:** [GitLab release badge wording](/release-notes/product/2026#product-2026-08-07)
* **Bug fix:** [Same-second commit ties no longer freeze a PR's head commit](/release-notes/product/2026#product-2026-08-07)

### Features

#### First-class GitLab authentication <a href="#self-hosted-gitlab-authentication-values" id="self-hosted-gitlab-authentication-values"></a>

The Hub's GitLab credentials are now first-class chart values under `hub.gitlab.*`, replacing the documented `hub.extraEnv` + `hub.volumes` passthrough. Declare one `hub.gitlab.tokens` entry per top-level GitLab group (`{group, host?, baseUrl?}`), and put the group access tokens in a single operator-created Secret — named by `hub.gitlab.tokensSecret.secretName`, with one `<lowercase-group>.token` data key per entry — which the chart mounts at `/secrets/gitlab`, mirroring the multi-App GitHub pattern. A self-managed or GitLab Dedicated instance must be declared with `host` on its entry, or the Hub treats that host as GitHub.

The GitLab webhook signing secret is managed by `hub.gitlab.webhookSecret`: leave `secretName` empty and the chart generates a `<release>-gitlab-webhook` Secret that persists across upgrade and uninstall, or point it at a Secret you manage. Unlike GitHub, there is nothing to paste back anywhere — the Hub registers project hooks itself and stamps the secret on them. A per-entry `tokens[].webhookSecret` override remains available for advanced multi-instance isolation.

### Improvements

* The Dedicated setup documentation is restructured into three pages: an overview, a step-by-step setup guide that begins with private connectivity, and a standalone outbound PrivateLink guide written to be handed to a customer's networking team.
* The chart no longer requires GitHub configuration: GitHub-only, GitLab-only, and mixed GitHub-and-GitLab values all render, as long as at least one SCM is configured, and a partial configuration for either SCM still fails at render time with a specific message. The chart-managed GitHub webhook secret and the GitHub entries in the install notes are rendered only when GitHub is configured; GitHub-only output is unchanged.

## 2026-08-03 <a href="#self-hosted-2026-08-03-1" id="self-hosted-2026-08-03-1"></a>

**Helm chart version:** `3.6.1`\
**Lunar images version:** `3.6.0`\
**Product updates included:** None

### Breaking changes

* `grafana.securityContext` now applies only to the Grafana server container; the kiosk sidecar reads the new `grafana.kiosk.securityContext` value, which defaults to `{}`. If you set `grafana.securityContext`, copy the block to `grafana.kiosk.securityContext` when upgrading — otherwise the kiosk container silently loses those settings, and in a namespace with enforced Pod Security Standards the pod can be rejected outright.

### Features

* The bundled Grafana Deployment can now run more than one replica via `grafana.replicaCount`. Scaling beyond one requires pointing Grafana's backend store (sessions, organizations, annotations — distinct from the read-only dashboard datasource under `grafana.provisioning.dbPassword`) at Postgres through the new `grafana.db` values, which wire `GF_DATABASE_*` on the Grafana container; the chart fails at render time otherwise, since Grafana's default per-pod SQLite backend cannot be shared across replicas. The new `grafana.topologySpreadConstraints` value spreads replicas across nodes, defaulting to a soft node spread that is a no-op at a single replica.

### Improvements

* The kiosk sidecar in the Grafana pod — the nginx proxy that injects `?kiosk` into dashboard URLs — is now configurable through `grafana.kiosk`: `image.repository` and `image.tag` (previously hardcoded to `nginx:1-alpine`, now defaulting to the pinned `nginx:1.31.3-alpine` so the build no longer drifts per node), `resources`, and `securityContext`.
* All three containers in the Grafana pod (`grafana`, `kiosk`, and `provision-reconverge`) now have liveness and readiness probes, so Kubernetes detects and restarts a wedged container; previously none were probed.
* The Grafana provisioning Job now honors `grafana.annotations` and `grafana.podAnnotations`, matching the existing Hub and Operator behavior.

## 2026-07-28 <a href="#self-hosted-2026-07-28-1" id="self-hosted-2026-07-28-1"></a>

**Helm chart version:** `3.5.0`\
**Lunar images version:** `3.5.0`\
**Product updates included:** [2026-07-28](/release-notes/product/2026#product-2026-07-28)

### Product updates

* **Feature:** [Release Ledger dashboard](/release-notes/product/2026#product-release-ledger-dashboard)
* **Feature:** [Release history on component details](/release-notes/product/2026#product-2026-07-28)
* **Improvement:** [Failing collectors no longer retry the whole collection](/release-notes/product/2026#product-2026-07-28)
* **Improvement:** [Clearer errors when repository access is denied](/release-notes/product/2026#product-2026-07-28)
* **Bug fix:** [after-json collectors run without prior data](/release-notes/product/2026#product-2026-07-28)
* **Bug fix:** [Newest write wins in merged Component JSON](/release-notes/product/2026#product-2026-07-28)
* **Bug fix:** [Release and PR checks settle when nothing gates them](/release-notes/product/2026#product-2026-07-28)
* **Bug fix:** [Runs dashboard drill-down lands on the sub-component](/release-notes/product/2026#product-2026-07-28)

### Improvements

* The Hub's `http.app.server.duration` metric no longer carries a per-repository `github_repo` attribute. On installations tracking many repositories, that attribute multiplied the metric's active series enough to overwhelm a Prometheus scraping the Hub; the bounded `github_owner` attribute remains.
* Hub queue jobs that fail but still have retry attempts left now log at WARN; ERROR is reserved for a job's final, terminal attempt. An alert keyed on the Hub's `queue job failed` ERROR line now fires only when a job has exhausted its retries, not on transient failures that recover.

### Bug fixes

* The Hub no longer crash-loops at startup with `bundle backfill failed ... configuration already exists` after an earlier boot failed partway through installing the current Lunar configuration; the boot-time recovery now replaces the leftover on-disk copy instead of tripping over it on every subsequent start.

## 2026-07-21 <a href="#self-hosted-2026-07-21-1" id="self-hosted-2026-07-21-1"></a>

**Helm chart version:** `3.4.2`\
**Lunar images version:** `3.4.2`\
**Product updates included:** [2026-07-21](/release-notes/product/2026#product-2026-07-21)

### Product updates

* **Improvement:** [Repository checkouts for `after-json` collectors](/release-notes/product/2026#product-2026-07-21)
* **Improvement:** [Long assertion messages truncated in pull-request feedback](/release-notes/product/2026#product-2026-07-21)
* **Improvement:** [Cross-replica policy bundle caching](/release-notes/product/2026#product-2026-07-21)
* **Bug fix:** [Checks no longer stuck pending after a duplicate `after-json` trigger](/release-notes/product/2026#product-2026-07-21)
* **Bug fix:** [`after-json` collectors now settle on GitLab and GitHub Enterprise Server components](/release-notes/product/2026#product-2026-07-21)
* **Bug fix:** [Correct host and SCM provider for Hub-triggered runs on GitLab and GitHub Enterprise Server](/release-notes/product/2026#product-2026-07-21)
* **Bug fix:** [Operator restarts no longer strand in-flight script runs](/release-notes/product/2026#product-2026-07-21)
* **Bug fix:** [Skipped required checks no longer fail the Lunar pull-request status](/release-notes/product/2026#product-2026-07-21)

### Improvements

* The Operator's Kubernetes API client rate limits are now configurable through the `OPERATOR_K8S_QPS` and `OPERATOR_K8S_BURST` environment variables, with defaults raised to 50 QPS / 100 burst from the Kubernetes client's stock 5 / 10. The stock limit throttled script-run pod creation and cleanup, capping script execution throughput well below `OPERATOR_MAX_CONCURRENT` and node capacity; large installations can raise the new settings further to saturate more nodes.

## 2026-07-20-2 <a href="#self-hosted-2026-07-20-2" id="self-hosted-2026-07-20-2"></a>

**Helm chart version:** `3.4.1`\
**Lunar images version:** `3.4.0`\
**Product updates included:** None

### Features

* New `operator.scriptInitContainerSpec` and `operator.scriptSidecarContainerSpec` Helm values set the container spec for the init and sidecar containers the Operator injects into each script pod (one of each, shared across all script types), with the same overlay rules as the `operator.scriptContainerSpec*` settings: you set `resources`, env, and so on, while the Operator overlays the image, env, and volume mounts. Set `resources` here so the init and sidecar containers satisfy a namespace `ResourceQuota` or `LimitRange` that requires requests; leaving them empty (`{}`) keeps the Operator's built-in defaults.

## 2026-07-20-1 <a href="#self-hosted-2026-07-20-1" id="self-hosted-2026-07-20-1"></a>

**Helm chart version:** `3.4.0`\
**Lunar images version:** `3.4.0`\
**Product updates included:** [2026-07-20](/release-notes/product/2026#product-2026-07-20)

### Product updates

* **Improvement:** [Reduced repository sync churn on large installations](/release-notes/product/2026#product-2026-07-20)
* **Bug fix:** [Archived GitHub repositories sync correctly](/release-notes/product/2026#product-2026-07-20)

### Bug fixes

* A tracked repository that has no commits yet — created but never pushed — no longer causes repository sync to fail and retry indefinitely, flooding the Hub logs with errors on every reconcile. The Hub now treats an empty repository as a valid state on both GitHub and GitLab, logging a single warning and skipping it until commits arrive.
* Script pods created by the Operator are no longer rejected in namespaces that enforce resource requests through a `ResourceQuota` or `LimitRange` (`must specify requests.cpu for: init,sidecar`): the init and sidecar containers the Operator injects into every script pod, which previously declared no resources, now carry small built-in CPU and memory requests and limits on every install — init `100m`/`128Mi` requests with `500m`/`512Mi` limits, sidecar `25m`/`64Mi` with `100m`/`128Mi` — independent of the script container's own, possibly much larger, resources. Clusters whose limits require different values can override them with the new `OPERATOR_SNIPPET_INIT_CONTAINER_SPEC` and `OPERATOR_SNIPPET_SIDECAR_CONTAINER_SPEC` environment variables on the Operator, each a JSON-encoded container spec of which only `resources` is applied; a malformed value fails the Operator at startup rather than at pod creation.

## 2026-07-17 <a href="#self-hosted-2026-07-17-1" id="self-hosted-2026-07-17-1"></a>

**Helm chart version:** `3.3.0`\
**Lunar images version:** `3.3.0`\
**Product updates included:** [2026-07-17](/release-notes/product/2026#product-2026-07-17)

### Product updates

* **Feature:** [Collector data dependencies: the `after-json` hook](/release-notes/product/2026#product-after-json-collector-hook)
* **Bug fix:** [Dashboards render GitLab components and merge requests correctly](/release-notes/product/2026#product-2026-07-17)
* **Bug fix:** [Push processing no longer dropped when GitHub times out a webhook delivery](/release-notes/product/2026#product-2026-07-17)
* **Bug fix:** [Runs dashboard Queued tab permission fix](/release-notes/product/2026#product-2026-07-17)

### Improvements

* The init and sidecar images of Operator-spawned script pods (`lunar-snippet-init`, `lunar-snippet-sidecar`) now run cleanly as a non-root user: each bakes a writable home directory (`/home/lunar`, owned by uid 1000 and group-writable by the root group), so a non-root `runAsUser` set through `operator.scriptPodSecurityContext` or `operator.scriptContainerSpec*` — as required by a cluster's restricted Pod Security Standards — no longer fails on writes under `$HOME`, and no custom-built images are needed. The images still start as root by default; nothing changes unless a non-root security context is configured.

### Bug fixes

* A container-level `securityContext` configured through the `operator.scriptContainerSpec*` chart values now applies to every container in a script pod the Operator creates — the init and sidecar containers as well as the script containers. Previously only the script containers carried it, so clusters enforcing the "restricted" Pod Security Standard flagged the init and sidecar containers for not dropping capabilities, which a pod-level `securityContext` cannot fix because fields like `capabilities` exist only at the container level. No configuration change is needed, and installs that set no container-level `securityContext` are unaffected.

## 2026-07-14 <a href="#self-hosted-2026-07-14-1" id="self-hosted-2026-07-14-1"></a>

**Helm chart version:** `3.2.1`\
**Lunar images version:** `3.2.0`\
**Product updates included:** None

### Features

* Operator pods can now be spread across nodes or zones with `operator.topologySpreadConstraints` — set it when running more than one Operator replica. It is empty by default, so no constraints are applied unless configured.

### Improvements

* The Hub's pre-install/pre-upgrade database migration Job now honors `hub.annotations` and `hub.podAnnotations`, applying them to the Job and its pod to match the annotation controls already available on the long-running components.

## 2026-07-13 <a href="#self-hosted-2026-07-13-1" id="self-hosted-2026-07-13-1"></a>

**Helm chart version:** `3.2.0`\
**Lunar images version:** `3.2.0`\
**Product updates included:** [2026-07-13](/release-notes/product/2026#product-2026-07-13)

### Product updates

* **Bug fix:** [GitLab-hosted Lunar configuration fetch fix](/release-notes/product/2026#product-2026-07-13)
* **Bug fix:** [Webhook registered before the initial repository sync](/release-notes/product/2026#product-2026-07-13)

### Security

* The dashboards deploy image, `ghcr.io/earthly/lunar-dashboards`, now builds its bundled `grpcurl` from source with a current Go toolchain instead of shipping the upstream prebuilt binary, clearing the fixable critical CVEs image scanners flagged in it (Go standard-library CVEs including CVE-2024-24790 and CVE-2025-22871, and GHSA-p77j-4mvh-x3m3 in `google.golang.org/grpc`). Upgrading the chart pulls the patched image; the image's behavior is unchanged.

### Features

* New `operator.scriptPodAnnotations` and `operator.scriptPodSecurityContext` chart values set pod `annotations` and the pod-level `securityContext` (`fsGroup`, `runAsUser`, `seccompProfile`, …) on every script pod the Operator creates, rendered as the JSON-encoded `OPERATOR_SNIPPET_POD_ANNOTATIONS` and `OPERATOR_SNIPPET_POD_SECURITY_CONTEXT` environment variables. The pod-level `securityContext` is the piece typically required to run script pods under a cluster's "restricted" Pod Security Standards; container-level security context remains configurable separately via `operator.scriptContainerSpec*`. A malformed JSON value fails the Operator at startup instead of at pod creation.

### Bug fixes

* Grafana dashboard provisioning — the `ghcr.io/earthly/lunar-dashboards` image the chart runs as a deploy Job and as the Grafana pod's reconverge sidecar — no longer fails at pod cold start when the Hub has not yet provisioned the Grafana database connection or Grafana's `dashboard.grafana.app/v2` API server is still registering. It now waits for both, bounded by `HUB_RESOLVE_TIMEOUT` and `V2_READY_TIMEOUT` (120 seconds each by default), instead of failing with `dashboards reconverge failed` until the next pod restart, and it fails fast with a clear error when the target Grafana is older than 12.0, the minimum version for the v2 dashboard schema.
* On installations running more than one Hub replica, SQL API materialization no longer runs concurrently on every replica — previously each replica rebuilt the same tables at the same time, adding redundant database load. At most one materialization now runs across the whole installation, and any additional refresh, including a manual `lunar sql refresh`, queues behind the one in progress.

## 2026-07-11 <a href="#self-hosted-2026-07-11-1" id="self-hosted-2026-07-11-1"></a>

**Helm chart version:** `3.0.0`\
**Lunar images version:** `3.0.0`\
**Product updates included:** [2026-07-11](/release-notes/product/2026#product-2026-07-11)

### Product updates

* **Breaking change:** [Duplicate imports must set unique names](/release-notes/product/2026#product-2026-07-11)
* **Bug fix:** [block-release checks hidden on pull requests](/release-notes/product/2026#product-2026-07-11)
* **Bug fix:** [Duplicated check rows when a component resolves to several latest commits](/release-notes/product/2026#product-2026-07-11)
* **Bug fix:** [One check row per applying policy import](/release-notes/product/2026#product-2026-07-11)
* **Bug fix:** [Closed pull requests no longer stuck as Active](/release-notes/product/2026#product-2026-07-11)
* **Bug fix:** [Config-declared component metadata fix](/release-notes/product/2026#product-2026-07-11)
* **Bug fix:** [Orphaned check rows removed from the SQL API](/release-notes/product/2026#product-2026-07-11)
* **Bug fix:** [Stable names for duplicate policy imports](/release-notes/product/2026#product-2026-07-11)

### Migrations and upgrade notes

#### Grafana now runs the stock upstream server, with dashboards deployed over its API <a href="#self-hosted-grafana-stock-server-dashboards-deploy-image" id="self-hosted-grafana-stock-server-dashboards-deploy-image"></a>

Lunar no longer ships a custom Grafana server image. The chart now runs the stock `grafana/grafana` server, and Lunar's panel plugins, datasources, and dashboards are installed into it over Grafana's HTTP API by a new deploy image, `ghcr.io/earthly/lunar-dashboards`, which the chart runs as a deploy Job on install and upgrade. The same image can target a Grafana you operate yourself — including Grafana Cloud — resolving the Grafana endpoint and database connection from the Hub.

Database migrations now additionally create `grafana_user`, a read-only Postgres role that backs the Grafana datasource. It is created during the normal migration run and is covered by the same `CREATEROLE` privilege already required for `sqlapi_user`. The datasource connects with `sslmode=require` by default; plain Postgres without TLS must set `HUB_GRAFANA_DB_CONNECTION_OPTIONS` to `sslmode=disable`.

Two operational notes: Grafana plugins are now installed at pod start from grafana.com instead of being baked into the server image, so restricted-network installs need egress to grafana.com or plugins pre-installed out of band; and `ghcr.io/earthly/lunar-dashboards` is a private image, pulled with the same `imagePullSecrets` as the other Lunar images.

* Hub database migrations in this release re-key the `materialized_checks` table to one row per applying policy import and rename historical duplicate-named policy imports to stable names (see [Stable names for duplicate policy imports](/release-notes/product/2026#product-2026-07-11)), and clear the checks materialization watermark, so the first materialization run after upgrading rebuilds checks data from the full history; on large installations that run can take noticeably longer than usual. Run that rebuild with a single Hub replica — temporarily set `hub.replicaCount: 1` and scale back once the pass completes — so that replicas do not rebuild the same data concurrently. See [One check row per applying policy import](/release-notes/product/2026#product-2026-07-11).

## 2026-07-09 <a href="#self-hosted-2026-07-09-1" id="self-hosted-2026-07-09-1"></a>

**Helm chart version:** `2.17.0`\
**Lunar images version:** `2.8.0`\
**Product updates included:** [2026-07-09](/release-notes/product/2026#product-2026-07-09)

### Product updates

* **Feature:** [Component metadata for collectors and policies](/release-notes/product/2026#product-2026-07-09)
* **Bug fix:** [`ok-release` support for shared workflows](/release-notes/product/2026#product-2026-07-09)

### Security

* Patches `golang.org/x/net` and OpenSSL CVEs across all published images (Hub, Grafana, Operator, init, sidecar): `yq` is bumped to 4.53.3 and the Alpine base to 3.23.5. Upgrading the chart pulls the patched images.

## 2026-07-08-2 <a href="#self-hosted-2026-07-08-2" id="self-hosted-2026-07-08-2"></a>

**Helm chart version:** `2.16.0`\
**Lunar images version:** `2.7.0`\
**Product updates included:** None

### Features

* The Operator `Role` now grants `coordination.k8s.io/leases` (the `events` grant already existed) — the leader-election RBAC required when `operator.replicaCount > 1` on a leader-election-capable Operator image. The `Lease` lives in the Operator's script namespace, so no new Role or namespace is needed.
* New `operator.replicaCount` (default `1`) runs the Operator at two or more replicas. On an Operator image with Manager leader election (2.8.0 or later), script execution stays active-active across replicas while exactly one replica runs the pod-GC reconciler via a leader-election `Lease`; on an earlier image, more than one replica is still safe but runs duplicate (idempotent) GC passes. Chart 2.16.0 defaults to Operator 2.7.0, before leader election; override `operator.image.tag` with 2.8.0 or upgrade to chart 2.17.0 before raising `operator.replicaCount`, and account for Operator Postgres connections scaling with the replica count.

## 2026-07-08-1 <a href="#self-hosted-2026-07-08-1" id="self-hosted-2026-07-08-1"></a>

**Helm chart version:** `2.15.0`\
**Lunar images version:** `2.7.0`\
**Product updates included:** [2026-07-07](/release-notes/product/2026#product-2026-07-07)

### Product updates

* **Bug fix:** [Monorepo sub-component run attribution](/release-notes/product/2026#product-2026-07-07)
* **Bug fix:** [Catalog preservation for undeclared domains](/release-notes/product/2026#product-2026-07-07)

## 2026-07-06 <a href="#self-hosted-2026-07-06-1" id="self-hosted-2026-07-06-1"></a>

**Helm chart version:** `2.14.0`\
**Lunar images version:** `2.6.0`\
**Product updates included:** None

### Migrations and upgrade notes

* Upgrading without pinning `hub.replicaCount` moves an existing install from one Hub replica to two, doubling its pod and Postgres connection footprint. Each replica budgets about 85 connections (the `hub.db.maxOpenConns` / `maxPoolConns` / `operatorPoolSize` defaults of 40/40/5), so size Postgres `max_connections` to at least `replicaCount × 85` plus headroom for the migrate Job, `psql`, and superuser-reserved slots — about 200 for the two-replica default. Managed Postgres (RDS, Aurora, Cloud SQL) typically provisions well above this, but a bare Postgres at the default `max_connections=100` must be raised before upgrading. Do not front the Hub with a transaction-mode connection pooler — it breaks the Hub's session advisory locks and LISTEN/NOTIFY.

### Improvements

* The Hub now runs highly available by default: `hub.replicaCount` changes from `1` to `2` and `hub.podDisruptionBudget.enabled` from `false` to `true` (`maxUnavailable: 1`), so a default install tolerates losing one Hub pod and keeps a replica serving during voluntary node drains. Scale to 3 for more headroom, or set `hub.replicaCount: 1` (and disable the PodDisruptionBudget) for a minimal single-instance install — switching between the two is config-only, with no schema or state change either way.

## 2026-07-03 <a href="#self-hosted-2026-07-03-1" id="self-hosted-2026-07-03-1"></a>

**Helm chart version:** `2.13.0`\
**Lunar images version:** `2.6.0`\
**Product updates included:** None

### Improvements

* The Hub pod now runs a `preStop` hook (`hub.preStopSleepSeconds`, default 10 seconds) that sleeps before SIGTERM, giving Kubernetes time to deregister the pod from the Service so new requests stop arriving before the Hub drains; set it to `0` to disable. `hub.terminationGracePeriodSeconds` (default 60) covers this sleep plus the Hub's shutdown budget (`HUB_SHUTDOWN_TIMEOUT`, 45s).
* The Hub readiness probe now targets `/ready` (previously `/health`) with `failureThreshold: 1`, so the pod reports not-ready as soon as the Hub begins graceful shutdown and Kubernetes deregisters it. Liveness still targets `/health`, which is process-only, so a draining or briefly database-blocked pod is never restarted. Requires a Hub image that serves `/ready` — 2.6.0 or later, the chart's new default — so keep the chart and image versions in lockstep if you pin image tags.

## 2026-07-02-3 <a href="#self-hosted-2026-07-02-3" id="self-hosted-2026-07-02-3"></a>

**Helm chart version:** `2.12.0`\
**Lunar images version:** `2.5.0`\
**Product updates included:** None

### Breaking changes

#### Hub storage becomes ephemeral; the hub-data PVC is removed <a href="#self-hosted-hub-ephemeral-state-dir" id="self-hosted-hub-ephemeral-state-dir"></a>

The Hub's `/var/lib/lunar` directory is now an ephemeral `emptyDir` instead of a PersistentVolumeClaim. The Hub keeps no durable state on disk — only re-extracted runtimes and a rebuildable script-code cache, with script code served from S3 — so the ReadWriteOnce `hub-data` PVC is removed, along with the `hub.persistence` values (`enabled`, `storageClass`, `size`, `accessModes`). A new `hub.stateDir.sizeLimit` value (default `2Gi`) caps the ephemeral volume. With no shared volume to contend for, the Hub Deployment now rolls out with `RollingUpdate` at any replica count — the `Recreate` strategy and the 2.11.0 render-time guard that forced persistence off for multi-replica deployments are both gone.

Upgrading does not delete the existing `<release>-hub-data` PVC (it was created with `helm.sh/resource-policy: keep`); it is simply orphaned, so remove it manually to reclaim storage. Before upgrading, make sure the Hub is already running an image that serves script code from S3 (hub 2.5.0 or later, the chart default since chart 2.5.0) and has pulled the Lunar configuration at least once on such a version, so script bundles exist in S3 when new pods start with cold, empty state directories.

* The `hub.rootDir` value and the `HUB_ROOT_DIR` environment variable it rendered are removed. The Hub has no such configuration field, so the value never had any effect; delete it from your values file if present.

## 2026-07-02-2 <a href="#self-hosted-2026-07-02-2" id="self-hosted-2026-07-02-2"></a>

**Helm chart version:** `2.11.0`\
**Lunar images version:** `2.5.0`\
**Product updates included:** None

### Features

#### Multi-replica Hub baseline <a href="#self-hosted-hub-replica-count" id="self-hosted-hub-replica-count"></a>

The chart can now run the Hub with more than one replica. New values: `hub.replicaCount` (default `1`, unchanged behavior), `hub.terminationGracePeriodSeconds` (default `60`), `hub.topologySpreadConstraints` (default none), and an optional `hub.podDisruptionBudget` (default off, because a PodDisruptionBudget on a single replica can block voluntary node drains — enable it for HA).

A soft node spread applies by default (`maxSkew: 1` across `kubernetes.io/hostname` with `ScheduleAnyway`), so multi-replica deployments survive a node loss without extra configuration; override it via `hub.topologySpreadConstraints`. Running more than one replica requires `hub.persistence.enabled=false`, since Hub state lives in Postgres: the chart fails fast at render time when `replicaCount > 1` with persistence still enabled, instead of leaving replicas contending for a single ReadWriteOnce PersistentVolumeClaim. Rendering also fails if the PodDisruptionBudget sets both `minAvailable` and `maxUnavailable`, a combination the Kubernetes API rejects.

## 2026-07-02-1 <a href="#self-hosted-2026-07-02-1" id="self-hosted-2026-07-02-1"></a>

**Helm chart version:** `2.10.0`\
**Lunar images version:** `2.5.0`\
**Product updates included:** [2026-07-02](/release-notes/product/2026#product-2026-07-02)

### Product updates

* **Feature:** [Script resource-size profiles](/release-notes/product/2026#product-2026-07-02)
* **Feature:** [Split Lunar configuration files](/release-notes/product/2026#product-2026-07-02)
* **Improvement:** [Repository checkouts for catalogers](/release-notes/product/2026#product-2026-07-02)
* **Improvement:** [Durable webhook-triggered code collection](/release-notes/product/2026#product-2026-07-02)
* **Bug fix:** [Correct GHES and monorepo dashboard links](/release-notes/product/2026#product-2026-07-02)
* **Bug fix:** [No collector fan-out for empty monorepo pushes](/release-notes/product/2026#product-2026-07-02)

### Features

* New `hub.annotations`, `grafana.annotations`, and `operator.annotations` values render onto each workload's Deployment metadata, distinct from the existing `podAnnotations` values that apply to the pod template. `hub.annotations` and `grafana.annotations` previously existed as chart values but were never wired into a template; `operator.annotations` is new. All default to empty, so existing installs are unchanged.

## 2026-07-01-5 <a href="#self-hosted-2026-07-01-5" id="self-hosted-2026-07-01-5"></a>

**Helm chart version:** `2.9.0`\
**Lunar images version:** `2.5.0`\
**Product updates included:** None

### Features

* New `hub.db.maxOpenConns`, `hub.db.maxPoolConns`, and `hub.db.operatorPoolSize` values render to `HUB_DB_MAX_OPEN_CONNS`, `HUB_DB_MAX_POOL_CONNS`, and `HUB_MAX_OPERATOR_POOL_SIZE`. The defaults match the Hub's existing built-in values (`40`, `40`, and `5`), so single-replica installs are unchanged. Total Postgres connection demand is `replicaCount × (maxOpenConns + maxPoolConns + operatorPoolSize)`: multi-replica deployments should lower `maxOpenConns` and `maxPoolConns` to fit the server's `max_connections`, keep `maxPoolConns` at or above peak concurrent workers (the sum of `hub.maxWorkers.*`) to avoid serializing store queries, and consider a connection pooler at larger replica counts.

## 2026-07-01-4 <a href="#self-hosted-2026-07-01-4" id="self-hosted-2026-07-01-4"></a>

**Helm chart version:** `2.8.0`\
**Lunar images version:** `2.5.0`\
**Product updates included:** None

### Features

* New `hub.grpc.maxConnectionAge` and `hub.grpc.maxConnectionAgeGrace` values (defaults `30m` and `10m`) render to `HUB_GRPC_MAX_CONNECTION_AGE` and `HUB_GRPC_MAX_CONNECTION_AGE_GRACE`. Bounding connection age makes long-lived HTTP/2 clients reconnect periodically, so they redistribute across Hub replicas as the fleet scales and drop off a draining replica during a rollout instead of staying pinned to the replica they first reached. Clients transparently re-resolve on GOAWAY, so the defaults are safe to keep; set both to `0` to disable connection cycling on single-instance installs. Requires a hub image with these settings (2.5.0 or later, the chart default) — older images ignore the environment variables.

## 2026-07-01-3 <a href="#self-hosted-2026-07-01-3" id="self-hosted-2026-07-01-3"></a>

**Helm chart version:** `2.7.0`\
**Lunar images version:** `2.5.0`\
**Product updates included:** None

### Bug fixes

* Fresh installs no longer fail on the `hub-migrate` Job. As a pre-install hook, the Job runs before the chart's ServiceAccount exists, so referencing that ServiceAccount left the Job unschedulable until it timed out and `helm install` failed with `DeadlineExceeded`. The migrator only talks to Postgres, so it now runs under the namespace's `default` ServiceAccount. Upgrades were never affected — the ServiceAccount already existed from the prior release — and image pulls are unaffected because `imagePullSecrets` is set on the pod spec.

## 2026-07-01-2 <a href="#self-hosted-2026-07-01-2" id="self-hosted-2026-07-01-2"></a>

**Helm chart version:** `2.6.0`\
**Lunar images version:** `2.5.0`\
**Product updates included:** None

### Bug fixes

* The `hub-migrate` Job now renders `hub.extraEnv`, matching the Hub Deployment, so environment variables such as `HUB_SQLAPI_PASSWORD` — the only way the chart supplies SQL API credentials — also reach the migrator. Previously, on a fresh database the migration created the `sqlapi_user` role without a password, leaving the SQL API unable to authenticate. Only first-time installs were affected; an already-migrated database does not re-run the migration.

## 2026-07-01-1 <a href="#self-hosted-2026-07-01-1" id="self-hosted-2026-07-01-1"></a>

**Helm chart version:** `2.5.0`\
**Lunar images version:** `2.5.0`\
**Product updates included:** None

### Migrations and upgrade notes

#### Database migrations run as a pre-upgrade Job <a href="#self-hosted-hub-migrate-pre-upgrade-job" id="self-hosted-hub-migrate-pre-upgrade-job"></a>

Database migrations now run as a Helm `pre-install`/`pre-upgrade` hook Job, not at Hub boot. The `hub-migrate` Job runs `/bin/lunar-hub-migrate` once per release — before the Hub Deployment is updated — so migrations complete first and gate the rollout. The Hub server now asserts at boot that the database schema is current and refuses to start if it is behind, which also makes scaled-up or restarted pods safe: they never run migrations themselves. This replaces the previous boot-time, per-pod in-process migration and is a prerequisite for running the Hub with more than one replica.

The migrate Job requires Hub 2.5.0, the first release that ships `/bin/lunar-hub-migrate`. The chart and Hub versions must move together: with an older pinned Hub image the Job cannot run because the binary is absent, and Hub images from 2.5.0 onward no longer migrate at boot, so they require this chart version or an equivalent external migration step.

## 2026-06-24 <a href="#self-hosted-2026-06-24-1" id="self-hosted-2026-06-24-1"></a>

**Helm chart version:** `2.4.1`\
**Lunar images version:** `2.4.1`\
**Product updates included:** [2026-06-23](/release-notes/product/2026#product-2026-06-23)

### Product updates

* **Feature:** [Buildkite support](/release-notes/product/2026#product-buildkite-support)
* **Feature:** [GitHub Enterprise Server and multi-organization support](/release-notes/product/2026#product-github-enterprise-server-support)
* **Feature:** [Monorepo support: path-scoped components](/release-notes/product/2026#product-monorepo-path-scoped-components)
* **Bug fix:** [In-progress collector status](/release-notes/product/2026#product-2026-06-23)

### Security

* The `2.4.1` images carry high-severity Go dependency security updates (`golang.org/x/crypto`, `golang.org/x/net`, `golang.org/x/sys`, `jackc/pgx`). Upgrading the chart pulls the patched images.

### Features

* Buildkite ingestion is disabled by default for Self-hosted installations. To enable it, set `HUB_BUILDKITE_WEBHOOK_TOKEN`, expose a reachable Hub webhook endpoint, configure Buildkite to send events there, and use a CI/CD Tracer version with Buildkite support. See [Buildkite support](/release-notes/product/2026#product-buildkite-support).

### Improvements

* Operator logs now identify the script and component behind a script-runner pod OOM kill and report the memory limit it exceeded, so operators can identify which workload needs more memory.

## 2026-06-17-2 <a href="#self-hosted-2026-06-17-2" id="self-hosted-2026-06-17-2"></a>

**Helm chart version:** `2.4.0`\
**Lunar images version:** `2.3.1`\
**Product updates included:** None

### Features

* New optional `hub.github.apps[].host` and `hub.github.apps[].baseUrl` render a GitHub Enterprise Server host and API endpoint into the matching `HUB_GITHUB_APPS` entry. Both default to github.com behavior and are omitted when empty, so existing github.com-only multi-App configurations render unchanged. Chart 2.4.0 still defaults to Hub 2.3.1, which accepts the values but does not provide end-to-end GHES App authentication; use Hub 2.4.1 or upgrade to chart 2.4.1 for GHES token minting and per-host App keying.

## 2026-06-17-1 <a href="#self-hosted-2026-06-17-1" id="self-hosted-2026-06-17-1"></a>

**Helm chart version:** `2.3.1`\
**Lunar images version:** `2.3.1`\
**Product updates included:** [2026-06-17](/release-notes/product/2026#product-2026-06-17)

### Product updates

* **Feature:** [Out-of-band collection](/release-notes/product/2026#product-out-of-band-collection)
* **Feature:** [Cron collectors on pull requests](/release-notes/product/2026#product-2026-06-17)
* **Improvement:** [Incremental runs dashboard refresh](/release-notes/product/2026#product-2026-06-17)
* **Bug fix:** [Catalog preservation after interrupted syncs](/release-notes/product/2026#product-2026-06-17)
* **Bug fix:** [Atomic configuration publishing](/release-notes/product/2026#product-2026-06-17)
* **Bug fix:** [Completed checks for untouched monorepo components](/release-notes/product/2026#product-2026-06-17)
* **Bug fix:** [Configuration errors for policies without images](/release-notes/product/2026#product-2026-06-17)
* **Bug fix:** [Waiting status for queued script runs](/release-notes/product/2026#product-2026-06-17)
* **Bug fix:** [Relative creation times for queued runs](/release-notes/product/2026#product-2026-06-17)
* **Bug fix:** [Automatic cleanup of abandoned queued runs](/release-notes/product/2026#product-2026-06-17)

### Improvements

* The static, image-coupled `GF_*` settings (`GF_INSTALL_PLUGINS`, `GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS`, `GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH`, `GF_USERS_ALLOW_SIGN_UP`, `GF_USERS_DEFAULT_THEME`, `GF_FEATURE_TOGGLES_ENABLE`) moved out of the Grafana Deployment `env` and into the `lunar-grafana` image, so they version with the image and no longer shadow it. Override per-deployment via `grafana.extraEnv`. Requires a `lunar-grafana` image with these settings baked in — this chart release's default image (2.3.1) has them.

## 2026-06-05 <a href="#self-hosted-2026-06-05-1" id="self-hosted-2026-06-05-1"></a>

**Helm chart version:** `2.3.0`\
**Lunar images version:** `2.2.1`\
**Product updates included:** None

### Features

* New `operator.scriptPodPriorityClassName` (default `""`) sets the `priorityClassName` on every script pod the Operator creates, rendered as `OPERATOR_SNIPPET_POD_PRIORITY_CLASS_NAME`; empty leaves the field unset, so pods take the cluster's default priority. The referenced PriorityClass must already exist in the cluster. Useful for ensuring script pods are terminated before service pods in single-namespace setups, or for prioritizing workloads in shared scratch namespaces.

## 2026-05-27 <a href="#self-hosted-2026-05-27-1" id="self-hosted-2026-05-27-1"></a>

**Helm chart version:** `2.2.1`\
**Lunar images version:** `2.2.1`\
**Product updates included:** [2026-05-27](/release-notes/product/2026#product-2026-05-27)

### Product updates

* **Improvement:** [Reliable policy execution and result posting](/release-notes/product/2026#product-2026-05-27)
* **Improvement:** [Clear pull-request checks without required policies](/release-notes/product/2026#product-2026-05-27)
* **Improvement:** [Anonymous access to public repositories](/release-notes/product/2026#product-2026-05-27)
* **Improvement:** [Complete queued-run visibility](/release-notes/product/2026#product-2026-05-27)
* **Improvement:** [Runs dashboards scoped to the current configuration](/release-notes/product/2026#product-2026-05-27)
* **Improvement:** [Faster runs listings](/release-notes/product/2026#product-2026-05-27)
* **Improvement:** [Reliable webhook registration](/release-notes/product/2026#product-2026-05-27)
* **Bug fix:** [Error banners limited to recent errors](/release-notes/product/2026#product-2026-05-27)
* **Bug fix:** [Correct runs dashboard links](/release-notes/product/2026#product-2026-05-27)
* **Bug fix:** [Cataloger results isolated by configuration version](/release-notes/product/2026#product-2026-05-27)

### Improvements

* A new partial B-tree index on `snippet_runs (started_at DESC)` speeds up narrow-window Runs-dashboard queries (roughly 300× on a `started_at`-only filter, 6.5× on a one-day panel render). The Hub's migration runner applies it on startup; fresh environments build the index inline with a brief `ShareLock` on `snippet_runs`.

### Bug fixes

* Three Runs dashboard fixes in Grafana: the `[collectors]`/`[policies]` links from the component dashboard now land on populated rows, policy script names in the Queued tab render as clickable links, and the `Created` column displays relative time. Ships in `lunar-grafana` 2.2.1, this chart release's default Grafana image.

## 2026-05-26-2 <a href="#self-hosted-2026-05-26-2" id="self-hosted-2026-05-26-2"></a>

**Helm chart version:** `2.2.0`\
**Lunar images version:** `2.1.1`\
**Product updates included:** None

### Features

* Multi-App GitHub authentication: the new `hub.github.apps` list pairs each `{owner, appId, installId}` entry with a per-owner PEM key in a Secret you manage, referenced by `hub.github.appsSecret`; the chart renders the `HUB_GITHUB_APPS` JSON and mounts the Secret at `/secrets/github-apps`. Runtime support requires Hub 2.2.0 or newer, but chart 2.2.0 still defaults to Hub 2.1.1; override `hub.image.tag` or upgrade to chart 2.2.1. The legacy single-App `hub.github.app.*` configuration remains supported, and render-time validation enforces mutual exclusivity between the two modes.

## 2026-05-26-1 <a href="#self-hosted-2026-05-26-1" id="self-hosted-2026-05-26-1"></a>

**Helm chart version:** `2.1.0`\
**Lunar images version:** `2.1.1`\
**Product updates included:** None

### Features

* New `hub.secrets.<scope>.perKey` (default `false`) switches script-secret delivery for a scope from a single `HUB_<SCOPE>_SECRETS` environment variable to per-key injection that surfaces each Secret data key as `HUB_<SCOPE>_SECRET_<KEY>`, so one key can be rotated or added with `kubectl patch secret` without re-supplying the others. The Hub merges both shapes when both are configured (per-key wins on conflict), so keys can be migrated one at a time. This requires Hub 2.2.0 or newer, but chart 2.1.0 still defaults to Hub 2.1.1; override `hub.image.tag` or upgrade to chart 2.2.1 before enabling it.

## 2026-05-20 <a href="#self-hosted-2026-05-20-1" id="self-hosted-2026-05-20-1"></a>

**Helm chart version:** `2.0.0`\
**Lunar images version:** `2.1.1`\
**Product updates included:** None

### Breaking changes

* Webhooks can now be exposed on a separate ingress from the API: `hub.ingress` is reshaped into `hub.ingress.api` and `hub.ingress.webhooks`, and the chart computes per-component URLs from the right ingress. The previous single-host ingress configuration is no longer accepted — upgrading from 1.x fails at render time with migration messages: move `host` under both `api.host` and `webhooks.host`, and `grpcAnnotations`/`httpAnnotations` under `api.grpcAnnotations`/`api.httpAnnotations` (see the README section "Migrating from chart 1.x").
* `hub.grafanaURLBase` is renamed to `grafana.externalURL`, and rendering fails with a migration message if the old key is still set. The effective Grafana URL — rendered as `HUB_GRAFANA_URL_BASE` for the Hub's dashboard links and as Grafana's own `GF_SERVER_ROOT_URL` — now resolves from `grafana.externalURL`, then a chart-managed Grafana ingress host, and is otherwise left empty rather than guessed from the public webhook host, fixing OIDC redirect-URI breakage on split-host installs. Installs that expose Grafana through external routing must set `grafana.externalURL` explicitly.
* `hub.publicBaseURL` is replaced by `hub.webhookURL`, which is now optional: with chart-managed ingress it defaults to `https://<hub.ingress.webhooks.host>`, while bring-your-own-ingress installs must set it explicitly. Rendering fails with a migration message if the old key is still set.

## 2026-05-18 <a href="#self-hosted-2026-05-18-1" id="self-hosted-2026-05-18-1"></a>

**Helm chart version:** `1.0.2`\
**Lunar images version:** `2.1.1`\
**Product updates included:** None

### Features

* New `hub.maxWorkers.{collect,policy,cronCollect,cataloger}` values surface the Hub's `HUB_MAX_WORKERS_*` worker-concurrency caps through the chart. Defaults are `10`/`20`/`5`/`1` respectively; `0` means unlimited. This requires Hub 2.2.0 or newer, but chart 1.0.2 defaults to Hub 2.1.1, which ignores these variables; override `hub.image.tag` or upgrade to chart 2.2.1 to apply the caps.

## 2026-05-17-2 <a href="#self-hosted-2026-05-17-2" id="self-hosted-2026-05-17-2"></a>

**Helm chart version:** `1.0.1`\
**Lunar images version:** `2.1.1`\
**Product updates included:** None

### Bug fixes

* The broken `v2.1.1` image default is corrected to `2.1.1`; chart 1.0.0's defaults were not pullable without an override because the `v`-prefixed tag did not exist in the registry.

## 2026-05-17-1 <a href="#self-hosted-2026-05-17-1" id="self-hosted-2026-05-17-1"></a>

**Helm chart version:** `1.0.0`\
**Lunar images version:** `v2.1.1`\
**Product updates included:** [2025-10-20](/release-notes/product/2025#product-2025-10-20), [2025-12-19](/release-notes/product/2025#product-2025-12-19), [2026-05-15](/release-notes/product/2026#product-2026-05-15), [2026-05-17](/release-notes/product/2026#product-2026-05-17)

### Product updates

* **Feature:** [Component score trends](/release-notes/product/2025#product-2025-10-20)
* **Feature:** [Containerized script execution](/release-notes/product/2025#product-containerized-script-execution)
* **Breaking change:** [Status badge service removed](/release-notes/product/2026#product-2026-05-15)
* **Improvement:** [Policy checks complete when CI does not run](/release-notes/product/2026#product-2026-05-15)
* **Improvement:** [Background repository webhook management](/release-notes/product/2026#product-2026-05-15)
* **Improvement:** [Faster dashboard queries](/release-notes/product/2026#product-2026-05-15)
* **Improvement:** [Dashboard tabs, filters, and pagination](/release-notes/product/2026#product-2026-05-15)
* **Improvement:** [GitHub App-only authentication](/release-notes/product/2026#product-2026-05-15)
* **Improvement:** [Hourly SQL API materialization](/release-notes/product/2026#product-2026-05-15)
* **Bug fix:** [Component JSON retained across configuration changes](/release-notes/product/2026#product-2026-05-15)
* **Bug fix:** [Reliable Kubernetes cataloger results](/release-notes/product/2026#product-2026-05-15)
* **Improvement:** [Same-named repositories across GitHub organizations](/release-notes/product/2026#product-2026-05-17)
* **Bug fix:** [Scalar cataloger data no longer breaks dashboards](/release-notes/product/2026#product-2026-05-17)
* **Bug fix:** [Bounded retries for script image pull failures](/release-notes/product/2026#product-2026-05-17)

### Breaking changes

* Default image pulls switched from Docker Hub (`earthly/lunar-*`) to GitHub Container Registry (`ghcr.io/earthly/lunar-*`). Installations that pin Docker-Hub-only dev-build SHAs should point the image `repository` values back at Docker Hub.
* Lunar Hub no longer supports the legacy `HUB_GITHUB_TOKEN` personal-access-token authentication path. Before upgrading, create and install a GitHub App and configure its owner, App ID, installation ID, and PEM private key through `hub.github.app.*`; the Hub refuses to start without complete App credentials.
* Chart 1.0.0 replaces floating `main` image tags with a pinned default, but its exact `v2.1.1` tag was not published to GitHub Container Registry. This release therefore requires an image-tag override; chart 1.0.1 corrects the default to `2.1.1`.
* Chart values and templates now use "script" terminology in place of "snippet": the `operator.snippet*` keys (such as `snippetNamespace`, `snippetContainerSpec*`, `snippetPodNodeSelector`, and `snippetPodTolerations`) are gone — set the new `script*` equivalents instead. "snippet" survives only in the image names themselves.

### Features

#### Lunar Helm chart reaches 1.0 <a href="#self-hosted-helm-chart-early-access" id="self-hosted-helm-chart-early-access"></a>

Chart 1.0.0 concludes the early-access 0.x line, which ran for 26 releases from 0.1.0 (2026-02-09) through 0.8.2 (2026-05-17). Over those releases the chart grew from a Hub-plus-Grafana deployment into the full self-hosted stack: in-cluster execution of collectors and policies through the Operator, node-scheduling and image-pull controls, Hub persistence and public-URL configuration, chart-managed secrets and a required tenant ID, Grafana enabled by default with Postgres TLS support, and licence configuration.

The 0.x charts used floating `main` image tags from Docker Hub and had no production users according to the chart changelog. Chart 1.0.0 begins the release-by-release history and switches to versioned image defaults.

#### Licence-based deployment <a href="#self-hosted-licence-based-deployment" id="self-hosted-licence-based-deployment"></a>

Self-hosted Lunar installations are provisioned with a signed licence: a JWT issued by Earthly that carries the tenant identity, telemetry configuration, and optionally a container-registry pull credential. Lunar Hub verifies the licence at startup, applies the configuration it carries in place of hand-configured tenant and telemetry settings, and fails closed when the licence is missing, invalid, or expired.


# CLI and CI/CD Tracer

Lunar has been in active development since 2024. These release notes cover the `lunar` CLI and the CI/CD Tracer, which ship together on the same release line.

Use the annual pages in this section for the complete version history. See [Lunar CLI](/install/cli) for installation and [Lunar CLI Reference](/docs/lunar-cli) for current command documentation.


# 2026

## v4.0.0 (2026-09-15) <a href="#cli-v4-0-0" id="cli-v4-0-0"></a>

### Features

#### Signing in from the CLI: `lunar login`, `lunar logout`, and `lunar whoami` <a href="#cli-personal-sign-in-commands" id="cli-personal-sign-in-commands"></a>

`lunar login` signs in to the Hub with a GitHub or GitLab account. The CLI asks the Hub which forge OAuth apps it accepts, runs that forge's sign-in flow — a device code to confirm in the browser, or an authorization page with PKCE on a `127.0.0.1` callback, which can also be completed by pasting the redirect URL back on a headless machine — and stores the resulting Hub session and forge token in the OS keyring, or in a `0600` `~/.lunar/credentials.json` where there is no keyring. `lunar login --list` prints the forge, host, client id, flow, and scopes of every provider the Hub accepts, and `--forge` and `--host` pick one when the Hub accepts more than one.

Every later command uses the stored session automatically and renews both it and the forge token behind it five minutes before they expire; when renewal is no longer possible the command says so and tells you to run `lunar login` again. `LUNAR_HUB_TOKEN` still wins wherever it is set, so the service token and CI credentials keep working unchanged, and having no credential at all is not an error for commands that need none. `lunar whoami` reports who the Hub takes the current credential for — a signed-in person, a CI job, or the service token — with the forge, host, source, and session expiry. `lunar logout` forgets the stored login for the Hub and revokes the forge token where it can: at the instance on GitLab, through the Hub on GitHub when the operator configured the app's client secret, and otherwise by printing the page on which to revoke it by hand.

See [Signing in to Lunar as yourself](/release-notes/product/2026#product-personal-sign-in).

* When `LUNAR_HUB_TOKEN` holds the OIDC token a GitHub Actions or GitLab CI job was issued — recognized by its asymmetric signature, which neither the Hub service token nor a Hub session has — the CLI now trades it once for a Hub session that lasts the job, uses that session for every Hub call, and passes it to the processes it starts, so the agent and scripts started by `lunar ci-tracer run` hold the session rather than a token the forge expires within minutes. An exchange the Hub refuses fails the command with an error naming the cause: a token whose audience or issuer the Hub does not accept, a Hub with no session key configured, or too many failed attempts from the caller. Against a Hub too old to support the exchange the CLI only warns and continues with the token as it was. Because the Hub vends a forge credential only to the service token, a job traced under a CI OIDC token must set `LUNAR_GITHUB_TOKEN` itself — `${{ github.token }}` on GitHub Actions — or the tracer runs with step attribution and `LUNAR_COMPONENT_INFER` disabled. See [CI job identity for GitHub Actions and GitLab CI](/release-notes/product/2026#product-ci-job-identity).
* `lunar login` against a GitLab host runs the device authorization grant first, so signing in works from a machine with no browser of its own: the CLI prints a URL and a user code to complete anywhere. GitLab never advertises the endpoint in its OIDC discovery document, so Lunar uses `/oauth/authorize_device` when discovery omits it, and falls back to the authorization-code grant with PKCE on a loopback redirect only when the instance predates GitLab 17.9 or the OAuth application has its device authorization grant turned off — new GitLab applications have it off by default. A client id the instance refuses outright is reported instead of being retried through the fallback. The GitLab application still needs the `http://127.0.0.1/callback` redirect URI so the fallback works, and `lunar login --list` now reports the GitLab flow as `device when the app allows it, else authorization code (PKCE)`. See [Signing in to Lunar as yourself](/release-notes/product/2026#product-personal-sign-in).
* `lunar hub permissions` prints the permission table the connected Hub applies: one row per permission with its scope, its `READ` or `WRITE` access, the GitHub and GitLab role it requires, and what a CI job may do with it (`no`, `own repo`, `own commit`, `cataloged repo`, `config repo, protected branch`, or `yes`), with `*` marking a role that the `authorization:` block of the installed Lunar configuration changed from its default. `--verbose` lists the Hub calls each permission governs, with the request fields the authorization checks read and that call's own CI value; `--all` adds the fixed `internal`, `authenticated`, and `public` permissions; `--output-json` prints the same table as JSON, naming the configuration version the overrides came from; and `--defaults` prints the table compiled into the CLI, which needs neither a Hub connection nor a credential. Reading the table from a Hub requires Lunar images 4.0.0 or later, which is where the Hub began serving it.
* `lunar hub pull --dry-run` now validates the configuration file the in-repo path on the URI names — `lunar hub pull github://acme-corp/lunar-repo/lunar-config.dev.yml@main --dry-run` loads that file plus its own `lunar-config.dev.d/` fragments — and a local argument may name the configuration file itself as well as a directory, so `lunar hub pull ./lunar-config.dev.yml --dry-run` checks a non-default entry point before it is merged; a path that names nothing, climbs out with `..`, is absolute, or resolves through a symlink outside the repository fails the validation instead of falling back to the root `lunar-config.yml`. See [Choosing which configuration file a Hub loads](/release-notes/product/2026#product-configuration-entry-point).
* `lunar setup bootstrap` can now deposit a GitLab token that is not bound to a group: pass `--gitlab-token` without `--gitlab-group` and the token is deposited host-wide, for every group on the instance, instead of one top-level group. The new `--gitlab-host` names that instance and takes a bare hostname — lowercased, with no scheme, port, or path — defaulting to `gitlab.com`; it is rejected together with `--gitlab-group`, because a group deposit is keyed by the group alone. Group deposits are otherwise unchanged: the same flags deposit the token under the same per-group slot, and only the error text for a half-supplied pair changes, to `--gitlab-group requires --gitlab-token`. See [Instance-wide GitLab tokens and token pools](/release-notes/product/2026#product-gitlab-host-wide-token-pools).
* `lunar sql credentials ls` lists the personal SQL API credentials the Hub has issued — login, forge, host, Postgres role, status (active, expired, revoked, or dropped), expiry, and last rotation — as a table, or as JSON with `--output-json`, and `lunar sql credentials revoke <login>` ends one person's SQL access by disabling their role and terminating its open sessions. Both commands talk to the Hub and are refused for a CI job credential. See [Per-person SQL API credentials](/release-notes/product/2026#product-per-person-sql-api-credentials).
* The CI Tracer again accepts a GitHub token of its own, in `LUNAR_GITHUB_TOKEN`, for the two things it cannot read from the job's environment: step attribution, which reads the workflow definition and the actions it uses, and the changed files behind `LUNAR_COMPONENT_INFER`. Both only ever ask about the repository being built, so in GitHub Actions set the variable to the job's own `github.token` and give the workflow `contents: read` and `pull-requests: read`; on Buildkite, which issues no per-job token, provision one. A tracer running on the Hub service token that sets nothing keeps using the GitHub credential the Hub vends, so existing pipelines are unchanged. When no credential is available at all, step attribution and changed-path component inference are skipped with a log line naming the variable, while tracing, collection, and collector execution continue unaffected.

### Improvements

* `lunar policy bypass-release`, `lunar policy bypass-pr`, and `lunar policy bypass-rm` no longer fail locally with "could not determine who is creating this bypass" when no actor can be inferred: when the CLI is authenticated as a logged-in person, it leaves `--actor` unset and the Hub records the login it authenticated, refusing an `--actor` that names anyone else; with the Hub's service token the CLI still fills the actor in from `GITLAB_USER_LOGIN`, `GITHUB_ACTOR`, or `USER`, and the Hub rejects the request when none of them is set, asking for `--actor`. The `--actor` help text on both commands now states which of the two applies. See [Verified bypass actors](/release-notes/product/2026#product-2026-09-15).
* `lunar policy bypass-ls` now names why a bypass is no longer in effect instead of calling every spent row `expired`: `status` reads `spent — PR #N was merged or closed` for a PR-gate bypass whose pull or merge request has finished, and `superseded — a later push moved PR #N past sha <sha>` for one pinned to a commit the head has moved past, keeping `revoked <when> by <who>` and `expired` for the cases they describe. A commit-bound bypass has no expiry clock, so it is no longer labelled as if it had one, and the `--active` help now states the four bounds the filter applies.
* `lunar hub pull --dry-run` no longer refuses to start without forge access: with no `LUNAR_GITHUB_TOKEN`, `LUNAR_GITLAB_TOKEN`, or configured Hub, plugins are cloned anonymously and each unpinned component's default branch is read straight from the remote with plain git, so validating a configuration that references only public repositories needs no setup at all. `lunar collector dev`, `lunar policy dev`, and `lunar cataloger dev` resolve default branches the same way, including for GitLab-hosted components when only a GitHub token is set. A private repository is still refused by the remote — the lookup fails with a credentials error instead of hanging on git's username prompt — and wildcard components, which need the forge API, still need a credential.
* `lunar hub pull --dry-run`, `lunar collector dev`, `lunar policy dev`, and `lunar cataloger dev` can now use the token the forge's own CLI is signed in with — `gh` for the host in `LUNAR_GITHUB_HOST` (`github.com` by default), `glab` for gitlab.com — so someone already authenticated to `gh` or `glab` can work with private and wildcard components without arranging a second credential. It is the last source consulted: `LUNAR_GITHUB_TOKEN` and `LUNAR_GITLAB_TOKEN`, then the token of a `lunar login` session, then the Hub-vended credential for a service-token caller, all take precedence, and a service-token caller never shells out to `gh` or `glab`. A forge CLI that is absent, signed out, failing, or slow to answer counts as no token rather than an error, and one invocation is cut off after 5 seconds. A token read this way serves only the CLI's own clones and forge API calls — it cannot sign anyone in to the Hub, which accepts only forge tokens issued to its own OAuth app. When no source yields a token, the error now reads `` run `lunar login`, set LUNAR_GITHUB_TOKEN, or authenticate `gh` `` (and the GitLab equivalent) instead of naming only the first two.
* An operation that genuinely needs a forge credential — expanding a wildcard component, resolving `lunar collector dev --pr`, or reaching a private repository — now fails with an error naming the credential for that component's own forge: sign in with `lunar login`, or set `LUNAR_GITHUB_TOKEN` for a GitHub host and `LUNAR_GITLAB_TOKEN` for a GitLab one. Previously a GitLab component was told to configure a GitHub connection, or reported only that no client was configured for its host.
* On a machine with a stored login, `lunar collector dev`, `lunar policy dev`, `lunar cataloger dev`, and `lunar hub pull --dry-run` now clone repositories, resolve default branches, expand wildcard component names, and read a `--pr` with the signed-in person's own forge token, instead of asking the Hub to vend a credential. `LUNAR_GITHUB_TOKEN` and `LUNAR_GITLAB_TOKEN` still take precedence where they are set, a forge the person is not logged in to falls through to the existing chain, and a machine with no stored login behaves exactly as before.
* `lunar sql connection-string` now returns a credential of your own when you are signed in with `lunar login`, and the shared SQL API connection string when the Hub service token is used; a CI job credential is refused. The connection string is still the only thing written to stdout, so piping it into `psql` or into a configuration file works as before, while a personal credential adds a note on stderr naming the Postgres role, when it expires, that re-running the command rotates it, and that a connection pooler in front of the SQL API only authenticates the role if it resolves users dynamically — PgBouncer `auth_query` rather than a static user list. See [Per-person SQL API credentials](/release-notes/product/2026#product-per-person-sql-api-credentials).
* On a GitHub Actions `pull_request` event, the CI Tracer now reads the pull request's head commit and base branch from the event payload the runner writes for the job, instead of asking the GitHub API for the workflow run, and falls back to that API call only when the payload is missing or carries neither.
* The CI Tracer now installs its configuration from Lunar Hub instead of cloning the configuration repository and every `uses:` plugin repository, so a traced job no longer needs access to those repositories. The Hub serves only what the job needs — the components of the repository being traced, the collectors whose CI hooks apply to it, and each of those collectors' code — so a CI workload no longer receives policies, catalogers, or other repositories' components. Where the tracer cannot read the repository from its own environment, which is the case when it wraps a self-hosted runner and starts before any job exists, it installs the whole configuration exactly as before. Against a Hub that does not yet serve the configuration, the tracer falls back to cloning.

### Bug fixes

* A Hub host given as a URL now works instead of failing with `too many colons in address`: the `lunar` CLI and the CI Tracer strip an `http://` or `https://` prefix, a trailing slash, and surrounding whitespace from the resolved host, and bracket an unbracketed IPv6 literal, applying the same treatment to `--hub-host`, `LUNAR_HUB_HOST`, and the `hub.host` field of the Lunar configuration. A value that cannot be interpreted — one carrying a path, query, fragment, credentials, an unrecognized scheme, or a port, since the gRPC and HTTP ports have their own settings — now fails the command with an error naming the problem, the setting the value came from, and the bare host name to use instead, except under `--no-hub` or `LUNAR_NO_HUB`, where the host is ignored. Any host that already worked is left byte-identical.
* Running `lunar hub pull --dry-run` twice against the same configuration commit — for example validating a repository's development and production configuration files in one CI job — no longer fails the second run with `config validation failed: ... configuration already exists`. The throwaway draft the first run leaves behind, which is keyed by the configuration repository's commit alone, is now replaced rather than treated as a conflict.

## v3.18.0 (2026-08-26) <a href="#cli-v3-18-0" id="cli-v3-18-0"></a>

### Features

* `lunar policy ok-release` now prints the Hub-rendered verdict block verbatim when the Lunar configuration sets `customization.ok_release_template`, in place of the built-in summary line and the failing and bypassed check lists; when no template is configured, or the Hub cannot render one, the command prints its built-in output as before. Only the printed block changes — the verdict, the exit code, the polling progress messages, and `lunar policy ok-pr` are unaffected. See [Customizing the `ok-release` verdict output](/release-notes/product/2026#product-2026-08-25).

### Improvements

* `lunar policy ok-release` and `lunar policy ok-pr` no longer print the command's usage and flag list when they fail after their arguments have been parsed — an invalid component ID, an expired timeout, or an authentication failure — so the error and its remedy stay at the end of a CI log instead of being scrolled off by help text.
* `lunar policy ok-release` and `lunar policy ok-pr` now fail on the first response saying Lunar Hub has no component by that name, instead of polling every interval until `--timeout` — ten minutes by default — for the same verdict. Nothing in the run being gated creates a component, so waiting could not change the answer; the error names the invalid component ID and points at the Lunar catalog the name has to come from. An unknown component stays fatal under every `--fail-open` mode, and a component that exists but has not finished evaluating still polls to `--timeout` as before.

### Bug fixes

* The CI Tracer now selects a component whose declared repository identity differs in letter case from the identity the CI environment reports: host, owner, and repository name are compared case-insensitively when matching components to the repository the job runs in, when resolving the names listed in `LUNAR_COMPONENT`, and when falling back to a single component named after the repository. Previously a component authored as `github.com/Acme/Api` in a checkout the Git platform reports as `acme/api` matched nothing, so the job ran collectors for no component and collected nothing, without an error. A monorepo component's subdirectory path is still matched case-sensitively, because it is a file path, and branch matching is unchanged. See [Case-insensitive repository identity matching](/release-notes/product/2026#product-2026-08-19).

## v2.12.0 (2026-08-11) <a href="#cli-v2-12-0" id="cli-v2-12-0"></a>

### Improvements

* `lunar policy ok-release` and `lunar policy ok-pr` now print a loud warning when running in CI without a detectable workflow run ID — for example when `GITHUB_RUN_ID` is absent and `--workflow-id` was not passed — explaining how to pass `--workflow-id`; previously the gate silently blocked until its timeout.

## v2.11.0 (2026-08-07) <a href="#cli-v2-11-0" id="cli-v2-11-0"></a>

### Features

* `lunar setup bootstrap` now accepts `--gitlab-group` together with `--gitlab-token` to deposit a GitLab group access token — run once per top-level group — and `--github-owner` to deposit per-organization GitHub App keys for multi-organization installs. At least one Git platform credential is now required: `--github-app-pem`, the GitLab pair, or both. Existing single-organization GitHub invocations are unchanged.

## v2.10.0 (2026-08-04) <a href="#cli-v2-10-0" id="cli-v2-10-0"></a>

### Features

* `lunar policy ok-release` and `lunar policy ok-pr` accept a new `--fail-open` flag to exit successfully instead of blocking when no verdict can be obtained: a bare `--fail-open` covers an unreachable Hub, and `--fail-open=timeout` or `--fail-open=both` extend it to gate timeouts (the value must be attached with `=`). Authentication failures and an unknown component name stay fatal under every mode — a typo in a component name cannot silently pass — and a gate whose checks genuinely fail still blocks.
* `lunar policy bypass-release` and `lunar policy bypass-pr` record a time-bound override of a component's release or PR/MR merge gate, `lunar policy bypass-ls` lists a component's bypasses — including expired and revoked ones — and `lunar policy bypass-rm` revokes one early. `--reason` is recorded with the bypass, `--for` sets its duration, and `--sha`, `--pr`, and `--policy` narrow its scope; in CI, when neither `--sha` nor `--pr` is given, the bypass is scoped to the commit taken from `GITHUB_SHA` or `CI_COMMIT_SHA`. See [Break-glass gate bypasses](/release-notes/product/2026#product-break-glass-gate-bypasses).
* The new `lunar queue` commands inspect and clear the Hub's queue of script runs: `lunar queue status` shows how many runs are queued by script type, and `lunar queue clear <collectors|policies|catalogers|all>` deletes queued runs — useful for recovering from a misconfiguration that filled the queue with work that can only fail and retry. Without `--yes`, `queue clear` prints what it would remove without deleting anything, and runs that are already executing are never deleted.

### Improvements

* The CI Tracer now fetches the component catalog from the Hub only when a CI job names a component, caches it per repository, and asks only for changes since its last fetch, removing redundant Hub round-trips on every run. The new `LUNAR_CATALOG_FULL_REFRESH_PERIOD` variable (default `15m`) bounds how long a missed change can leave the cached catalog stale; `0` disables the periodic full refresh.

## v2.8.0 (2026-07-29) <a href="#cli-v2-8-0" id="cli-v2-8-0"></a>

### Bug fixes

* Fetching scripts from a public repository under an owner with no configured SCM credentials no longer emits a warning per reference: the designed anonymous-access fallback is logged at debug level, so a `lunar collector dev` run over a configuration referencing dozens of public plugin scripts no longer prints a wall of warnings that reads like a failure. A private repository under an unconfigured owner still fails loudly, with the error naming the missing credentials. See [Public repositories without a GitHub App](/release-notes/product/2026#product-2026-05-27).
* `lunar collector dev` and `lunar cataloger dev` now run code collectors from the component's subdirectory for monorepo components — the same working directory the Hub uses — instead of the repository root, so a collector no longer behaves differently in local development than in production. With `--component` the subdirectory must exist at the checked-out ref, and with `--component-dir` pointing inside a monorepo the component name (and `LUNAR_COMPONENT_ID`) now includes the subdirectory exactly as the Hub names it.
* An interrupted `lunar collector dev`, `lunar policy dev`, or `lunar cataloger dev` run no longer poisons every later run with `failed to install draft config: ... configuration already exists`. The throwaway draft configuration such a run leaves behind is now replaced on the next run instead of blocking it, so no manual deletion under `~/.lunar/drafts/` (or `lunar clear-cache`) is needed to recover.
* `lunar policy ok-release` and `lunar policy ok-pr` no longer fail on a transient connection error partway through their poll. When the Hub becomes briefly unreachable after the poll has already reached it — for example during a load-balancer blip under load — the command now prints `temporarily unable to reach remote server; retrying...` and keeps polling until `--timeout` instead of aborting the gate. A server that is unreachable from the very first call, or any non-transient error, still fails immediately.

## v2.7.0 (2026-07-21) <a href="#cli-v2-7-0" id="cli-v2-7-0"></a>

### Improvements

* `lunar collector dev`, `lunar policy dev`, `lunar cataloger dev`, and `lunar hub pull --dry-run` now work with GitLab-hosted components and plugins: set the new `LUNAR_GITLAB_TOKEN` environment variable (or let a configured Hub supply the credentials), and the CLI routes clone authentication, default-branch resolution, and wildcard component expansion to the matching SCM for each host, including GitLab's nested namespaces. With `--component-dir`, the component name is now derived from the checkout's actual remote host instead of the configured GitHub host.

### Bug fixes

* Fetching the component catalog from the Hub no longer fails with a gRPC `ResourceExhausted` error on very large catalogs: the maximum gRPC message size between the CLI, the CI Tracer, and the Hub is raised from 4 MB to 16 MiB, so the CI Tracer's periodic catalog refresh keeps working on installations with tens of thousands of components.

## v2.6.2 (2026-07-17) <a href="#cli-v2-6-2" id="cli-v2-6-2"></a>

### Features

* The new `lunar setup bootstrap` command performs the secret-deposit step of a Lunar Dedicated install: it generates the workload encryption key and the webhook secret, and deposits them together with the GitHub App private key (`--github-app-pem`) into the dedicated account's secret drop in AWS Secrets Manager, encrypted with the install's CMK. It requires `--tenant` and `--region` from the coordinates packet, and can deposit cross-account by assuming the write-only deposit role with `--deposit-role-arn` and `--external-id` (which must be given together), so the secrets never pass through Earthly's systems. Re-runs are safe: the generated encryption key and webhook secret are write-once and never overwritten, while the GitHub App key is updated on every run, so rotating it is just a re-run. The generated webhook secret is printed exactly once — on the run that deposits it — to be set in the GitHub App's "Webhook secret" field.

### Bug fixes

* `lunar` and the CI Tracer now place their state, cache, and config directories under a writable temp-based path when running as a non-root user whose home directory is unset or resolves to the filesystem root, instead of failing with `mkdir /.lunar: permission denied`; a usable home directory still resolves to `~/.lunar/` as before.

## v2.6.1 (2026-07-16) <a href="#cli-v2-6-1" id="cli-v2-6-1"></a>

### Breaking changes

* The `lunar grafana deploy-dashboards` command is removed. Lunar's Grafana dashboards, datasources, and panel plugins are now installed by the `ghcr.io/earthly/lunar-dashboards` deploy image — the Helm chart runs it automatically on install and upgrade, and the same image can be run directly with Docker or Podman against a Grafana you operate yourself — so no local CLI step or container runtime is involved in dashboard deployment anymore.

### Improvements

* `lunar sql connection-string` accepts a new `--grafana` flag that prints the connection string for the read-only Grafana datasource role instead of the SQL API one.

### Bug fixes

* The CI Tracer now detects scheduled runs — `GITHUB_EVENT_NAME=schedule` on GitHub Actions, `BUILDKITE_SOURCE=schedule` on Buildkite — and records their collections as periodic re-collections rather than CI collections pinned to the checked-out commit, so a scheduled pipeline no longer replaces the component's latest data with its own partial results. See [Scheduled CI runs no longer overwrite component data](/release-notes/product/2026#product-2026-07-16).

## v2.6.0 (2026-07-09) <a href="#cli-v2-6-0" id="cli-v2-6-0"></a>

### Features

* `lunar-config.yml` can now be validated before merge: `lunar hub pull --dry-run <repo>` runs the Hub's own load-and-validate pipeline — including `uses:` plugin resolution and component repository checks — without applying anything, exiting non-zero on validation errors; it needs GitHub access but no Hub connection, so it can run as a CI check on pull requests. A companion `lunar config schema` command prints a JSON Schema for editor autocomplete.

### Bug fixes

* Fixed a CI/CD Tracer deadlock while configuring signal-heavy traced processes that could hang a build indefinitely and hold its runner until the CI timeout. The deadlock surfaced most often when the Hub was unreachable.

## v2.5.0 (2026-07-08) <a href="#cli-v2-5-0" id="cli-v2-5-0"></a>

### Features

* New `lunar grafana deploy-dashboards` command installs Lunar's Grafana dashboards, datasources, and panel plugins into a Grafana instance you operate (self-hosted or Grafana Cloud). The Grafana endpoint and credentials are supplied by the Hub, the dashboard version is matched to the running Hub automatically, and a Docker or Podman runtime is required locally.

### Improvements

* The Hub gRPC and HTTP ports now default to 443 when not configured, and `lunar ci-tracer run` forwards the resolved Hub connection settings to the tracer process. A CI runner configured with only `LUNAR_HUB_HOST` previously failed the Hub-configured check and could silently run without instrumentation.
* `lunar collector dev` and `lunar policy dev` now work when the Lunar config is split across `lunar-config.d/` files, and fully local runs (`--component-dir` together with `--script`) no longer require GitHub or Hub credentials. Credential errors in the remaining modes now explain what to configure.

### Bug fixes

* The CI/CD Tracer now classifies Node.js-based GitHub Actions launched through a shebang entrypoint as steps; such actions were previously attributed as commands of the preceding step.
* Fixed `sudo` failing inside traced builds with `The "no new privileges" flag is set`: the CI/CD Tracer no longer sets `no_new_privs` on traced processes that hold `CAP_SYS_ADMIN`. Running `sudo` or other setuid binaries under tracing requires a self-hosted runner running as root with `CAP_SYS_ADMIN`; on runners without the capability the previous behavior is unchanged.

## v2.4.0 (2026-06-23) <a href="#cli-v2-4-0" id="cli-v2-4-0"></a>

### Security

* Updated high-severity Go dependencies (`golang.org/x/crypto`, `golang.org/x/net`, `golang.org/x/sys`, `github.com/jackc/pgx/v5`) to patched versions in the `lunar` CLI and CI/CD Tracer build.

## v2.3.2 (2026-06-19) <a href="#cli-v2-3-2" id="cli-v2-3-2"></a>

### Bug fixes

* Fixed collectors failing with `Hub connection details not provided` when a collector image carrying CLI v2.2.0 or later runs against a Hub older than 2.3.1: `lunar collect` inside a collector now falls back to the legacy stdout output those Hubs expect. Out-of-band `lunar collect --component` invocations still submit to the Hub directly.

## v2.3.1 (2026-06-19) <a href="#cli-v2-3-1" id="cli-v2-3-1"></a>

### Migrations and upgrade notes

* The CI/CD Tracer's GitHub Action now lives at `earthly/lunar-ci-tracer` (renamed from `earthly/lunar-ci-action`) — update workflow references to the new name. The separate `lunar-ci-agent-dist` binary distribution is retired: install the tracer through the `lunar` CLI with `lunar ci-tracer run` or `lunar ci-tracer install`.

### Bug fixes

* Pull-request checks now post on GitHub Enterprise Server: the CI/CD Tracer records the repository's GitHub host with each CI collection, so the Hub posts the resulting checks against the right GitHub instance. See [GitHub Enterprise Server and multi-organization support](/release-notes/product/2026#product-github-enterprise-server-support).

## v2.3.0 (2026-06-19) <a href="#cli-v2-3-0" id="cli-v2-3-0"></a>

### Features

* New `lunar ci-tracer install` command downloads and caches the CI/CD Tracer through the Hub without running it, for warming the cache at build time — for example when baking a runner image.
* The CI/CD Tracer now supports Buildkite. Start it from a Buildkite agent `command` hook to trace builds and run CI collectors; runs are collected even though Buildkite exposes no job or step boundaries, and setting `LUNAR_COMPONENT_INFER=true` enables changed-path attribution for monorepo builds. See [Buildkite support](/release-notes/product/2026#product-buildkite-support).
* The CI/CD Tracer resolves the GitHub host from the run environment (`GITHUB_SERVER_URL` on GitHub Actions, or the git remote on Buildkite), so runs on GitHub Enterprise Server repositories are attributed to the right component with no extra configuration; `LUNAR_GITHUB_HOST` remains available as an override. See [GitHub Enterprise Server and multi-organization support](/release-notes/product/2026#product-github-enterprise-server-support).

## v2.2.0 (2026-06-17) <a href="#cli-v2-2-0" id="cli-v2-2-0"></a>

### Features

#### Unified CLI and CI/CD Tracer releases <a href="#cli-unified-cli-tracer-release" id="cli-unified-cli-tracer-release"></a>

The `lunar` CLI and the CI/CD Tracer (previously called the CI agent) now release together under a single version. There is no separate `lunar-ci-agent` binary to install: the CLI fetches, verifies, and caches the tracer through your Hub on first use, and later runs reuse the cache.

Start the tracer with the new `lunar ci-tracer run` command. It resolves the tracer version pinned into the CLI at build time (override with `--version`), downloads the binary through the Hub on a cache miss, verifies its digest, and caches it under `~/.lunar/bin` (override with `LUNAR_BIN_DIR`). All other arguments are forwarded to the tracer unchanged.

Version numbering continues above both earlier release lines — the CLI was at v2.0.0 and the CI agent at v2.1.3 — which is why there is no v2.1.0.

* New `--component <github.com/owner/repo>` and `--sha <commit>` flags let `lunar collect` run outside a traced CI job — for example in a CD pipeline — to attach additional component JSON to an existing component at a specific commit. The flags fall back to `LUNAR_COMPONENT_ID` and `GITHUB_SHA` when unset, and an optional `--pr` associates the write with a pull request. See [Out-of-band collection](/release-notes/product/2026#product-out-of-band-collection).

### Improvements

* When `LUNAR_COMPONENT_INFER=true` is set, changed-path component inference on GitHub Actions now covers push events as well as pull requests, so a monorepo build on the default branch is attributed to the components whose files actually changed. See [Monorepo support: path-scoped components](/release-notes/product/2026#product-monorepo-path-scoped-components).
* `lunar` commands and the CI/CD Tracer automatically retry Hub requests interrupted by a brief Hub outage, such as a rolling restart during an upgrade, instead of failing the operation.
* The CI/CD Tracer resolves component default branches only for the repository it is tracing, instead of for every component at startup. This reduces GitHub API usage on busy runners and fixes Lunar config sync failures caused by exhausting the GitHub App rate limit.

### Bug fixes

* The CI/CD Tracer now bounds the Hub and GitHub API calls made from its tracing loop with timeouts, and job cancellation interrupts them. A stalled call could previously freeze the traced build or leave an orphaned tracer occupying a self-hosted runner after the job was cancelled.
* Fixed a CI/CD Tracer deadlock triggered when a traced process exited while the tracer was still configuring it. The wedged tracer could leave a self-hosted runner reporting online while no longer running jobs.
* The CI/CD Tracer no longer logs a warning for every fault signal seen in traced processes — managed runtimes such as the JVM, Go, and Node.js raise and handle these signals as part of normal operation. A crash is now reported only when a fault signal actually terminates the traced process.

## v2.0.0 (2026-05-17) <a href="#cli-v2-0-0" id="cli-v2-0-0"></a>

### Features

* The new `lunar licence` command group extracts Hub bootstrap artifacts from a licence JWT locally, before any Hub is running in the cluster: `lunar licence verify` validates the licence against the trust list embedded in the binary and prints a summary; `lunar licence pull-secret` generates a Kubernetes `imagePullSecret` manifest for pulling Lunar images from `ghcr.io` (with `--namespace`/`-n`, `--name`, and `--out`/`-o`); and `lunar licence registry-token` prints the GHCR pull token for use with `docker login`. All subcommands accept `--licence-file`, or the `LUNAR_LICENCE_FILE` environment variable.

### Improvements

* The CI/CD Tracer continues running when Hub-backed startup setup fails — fetching runtime logging configuration or completing the initial sync — instead of aborting the CI job; set `LUNAR_STRICT_MODE=true` to keep the previous fail-closed startup behavior.

### Bug fixes

* `lunar collector dev --component` checks out the branch configured for the component in the Hub catalog instead of always using the repository's default branch, and validates that the component exists in the catalog.
* The CI/CD Tracer retries its update checks while Lunar Hub has no current configuration published, instead of proceeding and later surfacing confusing "manifest not found" errors during observation.

## v1.1.2 (2026-05-07) <a href="#cli-v1-1-2" id="cli-v1-1-2"></a>

### Breaking changes

* The CI/CD Tracer no longer accepts a GitHub personal access token for runtime authentication: the `LUNAR_GITHUB_TOKEN` runtime path is removed, and the tracer always uses GitHub App installation tokens vended by Lunar Hub, which was already the default path. Remove `LUNAR_GITHUB_TOKEN` from tracer environments; a Hub connection is now required for GitHub API access.

### Features

* The new `lunar diagnose bundle` command captures a self-contained troubleshooting snapshot of a deployed Lunar stack — descriptions and logs for every Lunar pod plus database statistics — packaged as a single `tar.gz` for sharing with support.

### Improvements

* CLI commands that take a component name — `lunar collector dev`, `lunar policy dev`, `lunar component get-json`, `lunar policy ok-release`, and `lunar policy ok-pr` — fall back to the `LUNAR_COMPONENT_ID` environment variable when no flag or argument is given.
* `lunar hub pull` no longer runs global catalogers automatically on every configuration sync; pass the new `--rerun-catalogers` / `-t` flag to opt in, mirroring the existing `--rerun-code-collectors` / `-l` flag. Per-component catalogers are unaffected and keep firing on their own hooks.
* The new `LUNAR_INSTALL_FILE_MAX_DISK_SIZE` setting (for example `200m` or `5gb`) caps the disk space used by locally cached Lunar configuration versions, pruning the oldest unused versions first; the default `0` leaves size-based pruning disabled.
* The CI/CD Tracer now handles attaching to a CI job that is already running — for example when launched as a step inside a GitHub Actions job — initializing from the current step instead of dropping all events, and it reports readiness only after its initial configuration sync with Lunar Hub succeeds.

### Bug fixes

* The CI/CD Tracer starts and idles when Lunar Hub has no configuration installed yet, instead of exiting with a startup sync failure and leaving the CI runner untraced.
* The CI/CD Tracer correctly traces GitHub code scanning runs that use the `dynamic` event type, including step resolution for CodeQL default-setup workflows whose definitions are not committed to the repository.
* The CI/CD Tracer no longer fails to initialize on GitHub-hosted runners: step scripts under the hosted runners' `work/_temp/` path are now recognized, where previously only the self-hosted `_work/_temp/` layout was detected.
* CI/CD Tracer startup update checks are now properly cancelled when their timeout fires (configurable via `LUNAR_UPDATE_CHECK_TIMEOUT`, default 5m), and attaching to an already-running process no longer logs misleading error and warning messages for expected mid-job attach conditions.

## v1.1.1 (2026-03-26) <a href="#cli-v1-1-1" id="cli-v1-1-1"></a>

### Improvements

* `lunar policy ok-release` and `lunar policy ok-pr` print which policy checks are blocking and their status, instead of only reporting that the component is unable to release or merge.
* `lunar policy ok-release` and `lunar policy ok-pr` poll until policy results are complete instead of returning a one-shot answer that could report failure before workflows and collectors had finished; polling is configurable with `--poll-interval` (default 10s) and `--timeout` (default 10m), and the commands print what they are still waiting on.
* A new `--ready-file` option makes the CI/CD Tracer create a file once tracing is active when attaching to a running process with `--pid`, so wrapper scripts can wait for tracing to start instead of polling `/proc`.
* `lunar` and the CI/CD Tracer now default their state, cache, and config directories to user-level paths under `~/.lunar/` when running as a non-root user, instead of system paths such as `/var/lib/lunar`; explicit overrides like `LUNAR_STATE_DIR` still take precedence.

## v1.1.0 (2026-03-19) <a href="#cli-v1-1-0" id="cli-v1-1-0"></a>

### Improvements

* `lunar collect` has a new `--array-append` flag that wraps the collected value in a single-element array; arrays at the same path are concatenated during the Component JSON merge, so repeated collector invocations can accumulate list values.
* `lunar collector dev` and `lunar policy dev` can now run directly inside a plugin directory (one containing `lunar-collector.yml` or `lunar-policy.yml`) without requiring a repository with a full `lunar-config.yml` setup.
* Collectors running on CI command hooks (`ci-before-command`, `ci-after-command`) now inherit the full environment of the traced CI process instead of a minimal sandbox, so variables such as `PATH` and `JAVA_HOME` set during CI steps are visible to collector scripts.

### Bug fixes

* The CI/CD Tracer terminates and reaps any remaining traced child processes when the root traced process exits, instead of leaving them ptrace-stopped.
* The CI/CD Tracer resolves steps from reusable workflow references, fixing "job not found in workflow definition" step-resolution failures in GitHub Actions jobs that call another workflow via `uses:`.
* The CI/CD Tracer detects shebang scripts during process tracing, so a tool like `npm` is reported as the command binary instead of its interpreter (`node`) in `binary.name` hook matching and `LUNAR_CI_COMMAND_BIN`.
* The CI/CD Tracer no longer fails to parse GitHub Actions workflow definitions that set a step's `env` to a runtime expression such as `${{ fromJSON(inputs.envs) }}`.

## v1.0.11 (2026-02-22) <a href="#cli-v1-0-11" id="cli-v1-0-11"></a>

### Security

* Git access tokens no longer appear in debug logs: git commands authenticate through the process environment instead of embedding the token in clone and remote URLs.

### Features

* The new `--component-dir` flag points `lunar collector dev` at a local directory as the target repository, instead of cloning the component's repo.
* The new `--config` option on `lunar collector dev` and `lunar policy dev` loads the Lunar config from a remote Git repository, accepting both `github.com/org/repo` and `github://org/repo@branch` forms.
* Collector command hooks support advanced matching of traced commands: exact or pattern-based binary matching (`name_pattern`, `dir_pattern`), positional and flag-based argument matchers (including both `--flag=value` and `--flag value` forms), and environment variable matchers.
* The new `--no-hub` option makes the Hub connection optional, so `lunar collector dev` and `lunar policy dev` can run without a configured Hub.
* Plugin scripts can be addressed with dot notation in dev commands — `myplugin.mycollector` selects one collector or policy, and passing just the plugin name runs all of the plugin's collectors or policies in a single invocation.
* The new `lunar secret` commands manage the secrets that collectors, policies, and catalogers use, storing values on the Hub at runtime instead of requiring them in the Hub's environment configuration.
* The new `lunar version` command prints the CLI version and the source commit it was built from.

### Improvements

* `lunar collector dev` resolves the component name and exposes it to the collector script's environment.
* Plugins referenced from directories outside the config repository — including symlinked paths — now install correctly in dev mode.
* Git clones are cached across plugin installs for faster repeated installs, draft (local dev) installs are no longer cached, and stale cached clones are purged periodically.
* `lunar hub pull` reports the full output of failed install commands, instead of a truncated error.
* Dependency installation is skipped for scripts that specify a container image, since the image already provides their dependencies.

### Bug fixes

* Fixed a permission-denied error when plugin installation copied directories with restrictive (read-only) permissions; failed partial copies are also cleaned up instead of blocking the next attempt.
* The `.git` directory is no longer copied during plugin installation, avoiding file-permission errors it used to cause.
* Referencing a non-existent local or remote plugin path in `uses` now produces a clear validation error instead of failing obscurely.

## v1.0.9 (2026-01-15) <a href="#cli-v1-0-9" id="cli-v1-0-9"></a>

### Improvements

* Release binaries are about 13% smaller: debug symbols are now stripped at build time.

## v1.0.8 (2026-01-15) <a href="#cli-v1-0-8" id="cli-v1-0-8"></a>

### Breaking changes

* The CI/CD Tracer binary (`lunar-ci-agent-linux-amd64`) is no longer attached to `lunar-dist` releases; it is published through its own release channel from this release onward. Update any automation that downloads the tracer binary from `lunar-dist`.

## v1.0.7 (2026-01-14) <a href="#cli-v1-0-7" id="cli-v1-0-7"></a>

### Features

* Releases now include a Linux arm64 build of the `lunar` CLI (`lunar-linux-arm64`), alongside the existing Linux amd64 and macOS (Apple silicon) binaries.

## v1.0.6 (2026-01-13) <a href="#cli-v1-0-6" id="cli-v1-0-6"></a>

### Breaking changes

* Collector, policy, and plugin names may no longer contain dots; configurations using dotted names are rejected when the Lunar config is loaded. The dot is reserved as the plugin/script separator.
* The deprecated `lunar-ci-agent install <config-URI>` command is removed; the CI/CD Tracer performs an initial configuration update check at startup instead, so a separate install step is no longer needed.

### Features

* `lunar collector dev` and `lunar policy dev` fetch the secrets a script needs from the Hub; a new `--secrets` flag supplies overrides or extra values.
* Collectors can now run at CI step boundaries on GitHub Actions using the new `ci-before-step` and `ci-after-step` hook types.

### Improvements

* `lunar collector dev` sets additional common environment variables for the collector script, bringing the local execution environment closer to what collectors receive in CI runs.
* `lunar collector dev` and `lunar policy dev` show a spinner with progress information about what is happening in the background.
* Collector and policy name resolution is more consistent — plugin names take part in resolution with fixed precedence — and name conflicts are detected with clearer error messages.
* The CI/CD Tracer now starts and runs without a locally installed Lunar configuration, picking it up automatically once the periodic update fetches it.
* CI step detection on GitHub Actions now accounts for composite actions, pre/post hooks, and conditional steps.

### Bug fixes

* The CI/CD Tracer no longer duplicates collector runs when several traced commands complete in a row.
* Collections from GitHub Actions matrix builds are now attributed to the correct matrix job instance instead of being conflated under the job's logical name.


# 2025

## v1.0.4 (2025-11-07) <a href="#cli-v1-0-4" id="cli-v1-0-4"></a>

### Features

* The new `lunar clear-cache` command resets the CLI's local caches.
* Collectors and policies that specify an `image` in their configuration now run inside Docker containers, including when run locally with `lunar collector dev` and `lunar policy dev`; Docker must be available for containerized dev runs. See [Containerized script execution](/release-notes/product/2025#product-containerized-script-execution).
* The new `lunar sql connection-string` command prints the PostgreSQL connection string for the SQL API.
* The new `lunar sql refresh` command rebuilds the materialized tables behind the SQL API.

### Improvements

* Cached repository clones now check for upstream updates instead of serving stale content.
* `lunar collector dev` now merges the component JSON deltas if there are multiple collections, mirroring how the Hub merges them; disable with `--merge=false`.

### Bug fixes

* Fixed an issue with searching parent directories for `lunar-config.yml`.
* GitHub tokens are now refreshed automatically for cached repository clones; previously, cached git directories could be left with expired tokens.

## v1.0.3 (2025-09-23) <a href="#cli-v1-0-3" id="cli-v1-0-3"></a>

### Features

* A new `--verbose` flag on `lunar collector dev` and `lunar policy dev` prints the script's own output alongside the command's results.
* Hub connection settings can now be defined under a `hub` section in `lunar-config.yml`; the CLI resolves connection settings with flags taking precedence over environment variables, which take precedence over the config file.

### Improvements

* Script dependencies are installed ahead of execution for all runtimes, with improved locking, making collector and policy runs faster and more reliable.
* `lunar collector dev` and `lunar policy dev` reuse previously cloned repositories and installed configurations between runs, making repeated runs much faster; a new `--no-cache` flag forces a fresh clone and install.
* `lunar policy dev` formats check results as a list instead of a table.
* `lunar hub run-code-collectors` accepts `--pr-max-age-days` and `--include-pr-commits`, matching `lunar hub pull`; the flag replaces the previous `--max-age-days`/`-m`.
* Collector and policy scripts run with a defined working directory and receive the `LUNAR_PLUGIN_ROOT` environment variable pointing at their plugin's root.

### Bug fixes

* File permissions and symlinks are preserved when plugin files are copied during installation.

## v1.0.2 (2025-09-04) <a href="#cli-v1-0-2" id="cli-v1-0-2"></a>

### Bug fixes

* Releases include the macOS (Apple silicon) binary again; it was missing from the v1.0.1 release.

## v1.0.1 (2025-09-03) <a href="#cli-v1-0-1" id="cli-v1-0-1"></a>

### Improvements

* Collector and policy scripts now have a Lunar-managed bin directory on their `PATH`, configurable via the `LUNAR_BIN_DIR` environment variable — install a custom binary there and scripts can invoke it directly.

## v1.0.0 (2025-09-03) <a href="#cli-v1-0-0" id="cli-v1-0-0"></a>

### Features

#### Initial public release <a href="#cli-initial-public-release" id="cli-initial-public-release"></a>

The `lunar` CLI and the CI/CD Tracer binary are now publicly distributed through GitHub releases on `earthly/lunar-dist`. This first release ships `lunar` for Linux (amd64) and macOS (Apple silicon), plus the CI/CD Tracer (`lunar-ci-agent`) for Linux (amd64).

The CLI provides commands for developing and testing collectors and policies locally (`lunar collector dev`, `lunar policy dev`), inspecting Component JSON (`lunar component get-json`), submitting data out of band (`lunar collect`), and administering the Hub configuration (`lunar hub pull`). The CI/CD Tracer instruments existing CI pipelines to collect SDLC metadata without workflow YAML changes.


# Policy Python SDK

Lunar has been in active development since 2024. Policy Python SDK release notes cover the `lunar_policy` API, policy semantics, compatibility, and packaging changes for policy authors.

Use the annual pages in this section for the complete package history. See [Policy](/plugin-sdks/python-sdk/policy) for current SDK documentation.


# 2026

## v0.2.3 (2026-03-17) <a href="#python-sdk-v0-2-3" id="python-sdk-v0-2-3"></a>

### Bug fixes

* Missing-data failure messages from `get_value` and `get_all_values` on a nested `Node` now report the full JSON path from the component JSON root instead of the path relative to the node, which could be as uninformative as `.`.


# 2025

## v0.2.2 (2025-12-19) <a href="#python-sdk-v0-2-2" id="python-sdk-v0-2-2"></a>

### Features

* Checks can now be marked as not applicable to a component (for example, a Go-specific check on a repository with no Go code): calling the new `Check.skip(reason)` method reports the check with the new `skipped` status (`CheckStatus.SKIPPED`) and discards any assertions recorded before the skip.

## v0.2.1 (2025-11-06) <a href="#python-sdk-v0-2-1" id="python-sdk-v0-2-1"></a>

### Features

* `Check` and `Node` gain `get_value_or_default(path, default)`, which returns the given default when there is no value at the path — instead of raising an error or, while data collection is still in progress, marking the check pending.

## v0.2.0 (2025-11-05) <a href="#python-sdk-v0-2-0" id="python-sdk-v0-2-0"></a>

### Breaking changes

#### Node-based data access API <a href="#python-sdk-node-data-access-api" id="python-sdk-node-data-access-api"></a>

The SDK's data access layer is rebuilt around the new `Node` class, which represents a location in the component JSON and navigates relative to that location. `Check.get_node(path)` returns a `Node`; nodes expose `get_value`, `get_all_values`, `exists`, and `get_node`, with paths resolved relative to the node and data accessed lazily. Checks and nodes can also be iterated — lists yield child nodes, dicts yield keys — and `items()` yields key/child-node pairs.

Upgrading from 0.1.x requires policy changes:

* `ComponentData` and `Path` are removed. Build a `Node` instead — `Node.from_bundle_file`, `Node.from_bundle_json`, `Node.from_component_json_file`, or `Node.from_component_json` — and pass it to `Check(name, node=...)`. When no node is given, checks still load the component JSON automatically from `LUNAR_BUNDLE_PATH`.
* `check.get(path)` is renamed to `check.get_value(path)`, and `check.get_all(path)` to `check.get_all_values(path)`. Both now default to `.`, the current node's value.
* Paths use a strict JSON path grammar — dot segments (`.foo`), bracketed keys (`['foo-bar']`), and array indexes (`[0]`) — that can be concatenated for relative navigation. General JSONPath expressions such as `$` roots, wildcards, and filters are no longer accepted. Because the SDK now parses paths itself, the `jsonpath-ng` dependency is gone and `lunar-policy` has no runtime dependencies.
* `CheckStatus.NO_DATA` is renamed to `CheckStatus.PENDING`; policy code that references the old member must be updated. The status value reported in check results is still `no-data`.

## v0.1.7 (2025-10-01) <a href="#python-sdk-v0-1-7" id="python-sdk-v0-1-7"></a>

### Improvements

* `lunar-policy` can now be installed on Python 3.9 and newer; earlier releases required Python 3.13.

## v0.1.6 (2025-08-20) <a href="#python-sdk-v0-1-6" id="python-sdk-v0-1-6"></a>

### Breaking changes

* Component data loaded with `ComponentData.from_file` or `ComponentData.from_json` must now include a `bundle_info` key, which carries the workflow-completion signal; Lunar-provided data includes it automatically, but hand-written fixtures used for local policy testing need to add it, and `ComponentData.from_component_json` accepts a new optional `bundle_info` argument.
* The `get_or_default` and `get_all_or_default` methods are removed; use `exists()` to probe for optional data, or catch `NoDataError` where a default value is genuinely wanted.

### Features

* The new `assert_exists(Path(path))` assertion and `exists(path)` helper test whether a path is present in the component data; `assert_exists` requires a `Path` object, while `exists` accepts a path string. Before all CI workflows for the commit have finished, a missing path keeps the usual `no-data` behavior; afterward, `assert_exists` fails the check and `exists` returns `False`.

### Improvements

#### Missing data becomes conclusive once collectors finish <a href="#python-sdk-collectors-finished-no-data" id="python-sdk-collectors-finished-no-data"></a>

Checks now distinguish data that has not been collected yet from data that is conclusively absent. Lunar records in the component data whether all CI workflows for the commit have completed, and the SDK reads that signal whenever a queried path has no data.

While workflows are still running, missing data behaves as before: the check ends early and reports `no-data`, and Lunar re-evaluates the policy as more data is collected. Once all workflows have finished, missing data is no longer treated as pending: `assert_exists` fails the check, `exists` returns `False`, and other assertions or `get` calls that hit a missing path report an `error` instead of leaving the check `no-data` indefinitely.

* `NoDataError` can now be imported directly from the package root (`from lunar_policy import NoDataError`), for policies that handle missing data themselves.

### Bug fixes

* Result reporting is fixed for policy scripts that define more than one check: each check's results are now emitted on a separate line, so checks after the first are no longer misread.

## v0.1.5 (2025-06-27) <a href="#python-sdk-v0-1-5" id="python-sdk-v0-1-5"></a>

### Features

* Policy scripts can now write ordinary log output to stdout: check results are emitted with the marker Lunar provides via `LUNAR_LOG_PREFIX`, so the platform can separate result reporting from anything else the script prints and capture the rest as script logs; when the marker is not set, output is unchanged.

### Bug fixes

* The missing-data check status is now reported as `no-data` instead of `no_data`, making the value consistent with the rest of Lunar; it previously surfaced as `no_data` in the checks SQL view.

## v0.1.4 (2025-06-03) <a href="#python-sdk-v0-1-4" id="python-sdk-v0-1-4"></a>

### Breaking changes

* The `submit()` method on `Check` is removed — results are submitted automatically when the `with` block exits — and `name` is now a read-only property; the previously public `description` attribute is now internal.
* The `SnippetData` class is renamed to `ComponentData`, matching what it holds — the component JSON a policy evaluates; update imports and references, the `from_file`, `from_json`, and `from_component_json` constructors are unchanged.
* `Check.failure_reasons` now returns a list of failure messages instead of a single comma-separated string.

### Features

* New `get_or_default` and `get_all_or_default` methods on `Check` return a default value instead of ending the check as `no_data` when a path has no data.

### Improvements

* Unexpected exceptions inside a check now produce a distinct `error` check status instead of being recorded as an assertion failure, separating broken policy code from genuine policy violations; the new value is available as `CheckStatus.ERROR`.
* When a check ends early because data is missing, the recorded result now includes a message naming the path that had no data (for example, `No data found for .sbom`).

## v0.1.3 (2025-05-28) <a href="#python-sdk-v0-1-3" id="python-sdk-v0-1-3"></a>

### Breaking changes

#### Explicit Path objects for component-data queries <a href="#python-sdk-explicit-path-objects" id="python-sdk-explicit-path-objects"></a>

Component-data lookups in assertions are now expressed with the new `Path` class, and assertions no longer guess whether a string argument is a JSON path: a plain string is always treated as a literal value.

```python
from lunar_policy import Check, Path

with Check("readme") as c:
    c.assert_true(Path(".readme.exists"))           # queries the component JSON
    c.assert_equals(c.get(".language"), ".net")     # ".net" is a literal value
```

Previously, any string beginning with `.` was interpreted as a path, which made it impossible to assert against literal values that merely look like paths. `Path` replaces the former `JsonPathExpression` class, and paths may now be written with an explicit `$` root prefix (for example `Path("$.readme.exists")`); either way they are evaluated from the root of the component JSON. String arguments to `get` and `get_all` are still interpreted as paths.

* The `assert_exists` and `assert_missing` assertions are removed; assert on the value itself instead.
* A check now ends at the first missing-data access, recording a single `no_data` result and skipping the rest of the block, instead of every assertion reporting `no_data` individually; accordingly, `get` and `get_all` raise `NoDataError` when a path has no data — handled automatically by the `Check` context manager — instead of returning `None` or an empty list.

### Features

* The new `fail()` method on `Check` explicitly fails a check with an optional failure message, for conditions the built-in assertions cannot express.
* New `status` and `failure_reasons` properties on `Check` expose the outcome of a check — useful for unit-testing policies; statuses are values of the `CheckStatus` enum, which replaces `OpResult`.
* The new `SnippetData.from_component_json` constructor loads a plain component JSON document as both the merged view and a single delta, so a policy can be exercised against a raw component JSON.

### Improvements

* An unhandled exception inside a `with Check(...)` block is now recorded on the check as a failed assertion carrying the error message, so the failure shows up in the check results rather than only in the script's output; the exception still propagates after the check is submitted.

### Bug fixes

* `lunar_policy.__version__` now reports the package version; it previously reported `0.0.1` regardless of the installed release.

## v0.1.2 (2025-05-20) <a href="#python-sdk-v0-1-2" id="python-sdk-v0-1-2"></a>

### Breaking changes

#### Package renamed to lunar-policy <a href="#python-sdk-lunar-policy-package-rename" id="python-sdk-lunar-policy-package-rename"></a>

The Policy Python SDK is now published on PyPI as `lunar-policy`, replacing the `lunar-checks` name; the package was originally published as `lunar-snippets`. Install it with `pip install lunar-policy` and import it as `lunar_policy`:

```python
from lunar_policy import Check
```

Aside from the name, the package contents are identical to `lunar-checks` 0.1.1. Update policy dependencies and imports to the new name; the older package names no longer receive updates.


