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

Enforcement needs four things: a policy engine to evaluate rules, a scaffolded service with a catalog-info.yaml to check, CI credentials to read the repo, and catalog groups so ownership rules can resolve.

Prerequisites for paved-road enforcement A policy engine, a service with a catalog entry, CI credentials, and catalog groups make paved-road checks enforceable. policy engine catalog-info.yaml CI OIDC creds catalog groups Enforceable conformance signal
Catalog groups are what let an ownership rule resolve a team rather than merely check a string is present.
  • 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

Enforcement is a loop: named deny rules run in CI on every PR and on a weekly schedule, the policy is pulled from a versioned bundle so a change rolls out by tag, and the result surfaces as a conformance badge in the portal.

Paved-road enforcement loop Deny rules run in CI on PR and schedule from a versioned bundle, surfacing as a portal conformance badge. Named deny rules actionable CI: PR + weekly catch drift Versioned bundle pull by tag Portal badge Tech Insights
The weekly schedule is what turns a one-time creation gate into a road that stays paved as services evolve.

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 * * *"

Validate the policy three ways: a conformant fixture passes, a non-conformant fixture fails with the specific message, and the policy itself parses cleanly.

Three policy validation checks A conformant fixture passes, a non-conformant fixture fails with a message, and the policy parses. Good → PASS conformant fixture Bad → FAIL specific message Policy parses conftest verify
Testing the failing fixture matters most — it proves the rule actually denies, not that it merely never runs.
# 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"

The enforcement pitfalls divide into rollout mistakes (a new deny rule with no grace period, a version mismatch) and mechanics mistakes (empty-file passes, stale badges, string-matched owners). The diagram groups them so the fix is either a rollout process or a check correction.

Paved-road enforcement pitfalls by class A no-grace deny rule and version mismatch are rollout mistakes; empty-file passes and string-matched owners are mechanics mistakes. Rollout no grace period version mismatch → warn-first + pin Mechanics empty-file pass owner string-match → assert file + ref
Introduce new rules as warn first, announce, then promote to deny — a wall of red erodes trust in the road.
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.