Enforcing Paved-Road Policies with Software Checks

This how-to shows how to turn paved-road standards into automated software checks that run in CI and surface as a conformance signal in the portal, so a service cannot silently drift off the supported route. It implements the enforcement half of Golden Paths & Paved Roads within Developer Experience & Self-Service Platforms, and the checks here align with the access boundaries from Role-Based Access Control Setup.

Prerequisites

  • A policy engine: open-policy-agent/conftest@^0.49.0 (wraps OPA ^0.63.0) for evaluating files in CI.
  • A scaffolded service produced by your golden path, containing a catalog-info.yaml.
  • CI with OIDC-federated ${GITHUB_TOKEN} so checks read repository contents without a long-lived secret.
  • Catalog Group entities so ownership checks can resolve teams.
  • Optional: @backstage/plugin-tech-insights@^0.5.0 if you want conformance scored inside the portal.

Exact Configuration

1. Write the paved-road policy

Express each standard as a deny rule. Keep rules small and individually named so failures are actionable.

# policy/paved_road.rego
# Requires OPA >= 0.63.0
package pavedroad

# Every component must declare an owner
deny[msg] {
  input.kind == "Component"
  not input.spec.owner
  msg := "spec.owner is required"
}

# Production components must publish TechDocs
deny[msg] {
  input.spec.lifecycle == "production"
  not input.metadata.annotations["backstage.io/techdocs-ref"]
  msg := "production components must set backstage.io/techdocs-ref"
}

# Only approved lifecycles are allowed
deny[msg] {
  not allowed_lifecycle[input.spec.lifecycle]
  msg := sprintf("lifecycle %q is not on the paved road", [input.spec.lifecycle])
}

allowed_lifecycle := {"experimental", "production", "deprecated"}

2. Add the check to the service’s CI

This workflow ships as part of the golden-path skeleton, so every scaffolded service is gated from its first commit.

# .github/workflows/paved-road.yaml
# Requires conftest >= 0.49.0
name: paved-road
on: { pull_request: {}, schedule: [{ cron: "0 6 * * 1" }] }
jobs:
  conformance:
    runs-on: ubuntu-latest
    permissions: { contents: read }
    steps:
      - uses: actions/checkout@v4
      - name: Install conftest
        run: |
          curl -sSfL https://github.com/open-policy-agent/conftest/releases/download/v0.49.0/conftest_0.49.0_Linux_x86_64.tar.gz \
            | tar xz conftest && sudo mv conftest /usr/local/bin/
      - name: Evaluate paved-road policy
        run: conftest test catalog-info.yaml --policy ./policy

The scheduled trigger is what makes this a paved road rather than a one-time gate: it re-evaluates conformance weekly so drift is caught even when no one opens a pull request.

3. Centralize the policy with a versioned bundle

Rather than copying policy/ into every repository, publish it once and pull a pinned version, so a standards change rolls out by bumping a tag.

# in the paved-road job, replace the local policy with a pinned bundle
# Requires conftest >= 0.49.0
      - name: Pull policy bundle
        run: conftest pull oci://${CONTAINER_REGISTRY}/paved-road-policy:v3.1.0
      - name: Evaluate
        run: conftest test catalog-info.yaml --policy ./policy

4. Surface conformance in the portal (optional)

Score the result with Tech Insights so the catalog shows a passing or failing badge on each component, giving owners visibility without reading CI logs.

# app-config.yaml
# Requires @backstage/plugin-tech-insights >= 0.5.0
techInsights:
  factRetrievers:
    pavedRoadConformance:
      cadence: "0 */6 * * *"

Validation

# Requires conftest >= 0.49.0
set -euo pipefail

# 1. A conformant entity passes
conftest test ./fixtures/good-catalog-info.yaml --policy ./policy
# expected: "PASS - ... - pavedroad"

# 2. A non-conformant entity fails with a specific message
conftest test ./fixtures/missing-owner.yaml --policy ./policy || true
# expected: "FAIL - ... - spec.owner is required"

# 3. Confirm the policy itself parses
conftest verify --policy ./policy
# expected: "PASS"

Edge Cases & Troubleshooting

Symptom Root Cause Resolution
Check passes locally but fails in CI CI evaluates an older pinned policy bundle Align the local policy tag with the conftest pull version in the workflow
Every service suddenly fails after a policy bump A new deny rule shipped without a migration window Introduce new rules as warn first, announce, then promote to deny after the grace period
Conformance badge never updates Tech Insights fact retriever cadence too slow or errored Lower the cadence, then inspect retriever logs for the failing fact
Policy passes empty files conftest given a path that matched nothing Assert the target file exists in the workflow before evaluating
OwnerPicker value rejected by owner rule Rule checks for a literal team name, not an entity ref Match on the presence of spec.owner and validate the ref format separately

Frequently Asked Questions

Should a failed paved-road check block the merge or just warn?

Block only the non-negotiable controls — ownership and security gates — and warn on the rest during a rollout. Hard-blocking every new standard on day one generates a wall of red checks and erodes trust in the paved road. Promote a rule from warn to deny after teams have had a window to remediate.

How do we enforce policies on services that predate the golden path?

Run the same scheduled conformance check across the whole catalog and open remediation tasks for non-conformant legacy services rather than blocking their pipelines immediately. This gives you an accurate picture of paved-road coverage and a backlog to drive down, without breaking teams that never opted in.