Quit Shifting Left: Pipelines as a Service

We’ve spent a decade telling developers to shift left. Own your builds. Own your tests. Own your security scanning, your SBOMs, your image signing. It sounded empowering, and for a while it was—but somewhere along the way “shift left” quietly became “shift everything onto the application team.” The result is ten teams each maintaining their own CI/CD pipeline, each reinventing the same build, and each accumulating their own subtle divergence from everyone else. That isn’t empowerment; it’s a tax.

So you have ten development teams, and every one of them needs a CI/CD pipeline. They’re all doing roughly the same thing—cloning a repo, building an artifact, running tests, baking a container image, and pushing it to a registry. The naive instinct is to hand each team a pipeline and let them maintain it. Six months later you have ten subtly different pipelines, ten different opinions about how to tag an image, and a platform team that spends its days answering “why does my build work but theirs doesn’t?” The problem was never that developers weren’t owning enough. The problem is that we asked them to own the wrong things. The build pipeline, the signing, the supply chain guardrails—that’s platform work, and it should be delivered to teams as a service, not homework assigned to every one of them. In this post, we’ll build a single, centrally-owned set of OpenShift Pipelines that every team can use without maintaining any pipeline code of their own—then layer on Red Hat Developer Hub for visibility and a full software supply chain security story on top.

The Core Problem

The tension we’re trying to resolve is ownership versus reuse. Platform engineering wants to own the pipeline definition—one canonical way to build a Java service, tested and secured and consistent across the org. Development teams want their builds to just work, to see their own pipeline runs, and to never think about Tekton internals. These two goals fight each other the moment you copy a pipeline into a team’s repository, because now the team owns a fork, and every improvement platform engineering makes has to be manually propagated across ten repos.

The whole architecture in this post is designed around a single principle: the pipeline definition lives in exactly one place, and teams reference it rather than copy it. Everything else—triggering, versioning, per-team visibility, supply chain security—hangs off that decision.

Triggering Builds with Pipelines as Code

The first building block is how a git event turns into a running pipeline. OpenShift Pipelines ships with Pipelines as Code (PAC), which handles exactly this. When a team pushes a commit or opens a pull request, PAC matches that event to a PipelineRun and executes it. Critically, PAC keys the execution namespace off a Repository custom resource, which means each team’s builds run in that team’s own namespace, not in some shared build farm.

Here’s the Repository CR for a team. It lives in the team’s namespace and tells PAC “events from this repo run here.”

apiVersion: pipelinesascode.tekton.dev/v1alpha1
kind: Repository
metadata:
  name: team-a-service
  namespace: team-a
spec:
  url: "https://github.com/your-org/team-a-service"
  git_provider:
    secret:
      name: pipelines-as-code-webhook
      key: token

PAC still needs a PipelineRun to exist in the team’s repository—it’s fundamentally a “find the run and execute it” tool, so there’s no way around having something in a .tekton/ directory. But here’s the trick: that file can be a thin, generic stub that carries nothing team-specific. Every team gets the exact same one, and platform engineering owns it.

apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
  name: build
  labels:
    backstage.io/kubernetes-id: "{{ repo_name }}"
  annotations:
    pipelinesascode.tekton.dev/on-event: "[push, pull_request]"
    pipelinesascode.tekton.dev/on-target-branch: "[main]"
spec:
  pipelineRef:
    resolver: cluster
    params:
      - name: kind
        value: pipeline
      - name: name
        value: java-build
      - name: namespace
        value: platform-pipelines
  params:
    - name: repo-url
      value: "{{ repo_url }}"
    - name: revision
      value: "{{ revision }}"

The values in double curly braces—{{ repo_url }}, {{ revision }}, {{ repo_name }}—are PAC’s dynamic variables, injected from the incoming git event at trigger time. Nothing in this file differs between teams. The repo name, the commit SHA, the clone URL all come from the event itself.

The Cluster Resolver: Reference, Don’t Copy

The single most important line in that stub is resolver: cluster. This is the Tekton Cluster Resolver, and it’s what makes the entire “one pipeline” premise work. Instead of embedding a pipeline definition in the PipelineRun, the resolver tells Tekton to go fetch the java-build pipeline from the platform-pipelines namespace at runtime, every single time a build fires.

Sit with the implications of that for a second. The pipeline definition is resolved fresh on every run. Platform engineering edits java-build once, in one namespace, and the next build in every team’s namespace picks up the change automatically. No team touches their .tekton/ file. No fork to propagate. The stub in the team’s repo names which pipeline to run; it says nothing about what that pipeline does.

The ownership boundary here is enforced with plain Kubernetes RBAC. Only platform engineering has edit rights on the platform-pipelines namespace. Teams get read-only access to the pipeline and task objects there—just enough for the cluster resolver to resolve them—and nothing more.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: pipeline-resolver-read
rules:
  - apiGroups: ["tekton.dev"]
    resources: ["pipelines", "tasks"]
    verbs: ["get", "list"]

You bind this into each team namespace so their pipeline service account can read the shared definitions. A team member who tries to oc edit pipeline java-build -n platform-pipelines gets a flat “forbidden.” Central ownership isn’t a convention or a gentleman’s agreement here—it’s physically enforced by the API server.

Deriving Configuration from the Project

Now we hit an interesting problem. If the pipeline is generic and the stub carries nothing team-specific, where does the build learn that Team A needs Java 21 and Team B needs Java 17? The wrong answer is to encode it in a Tekton custom resource that teams maintain—that’s just the fork problem wearing a different hat. The right answer is that the project already declares this, in the one place developers actually edit it: the pom.xml.

The pipeline clones the repo before it does anything else, so the pom is
right there on the workspace. Rather than duplicating the Java version into
YAML somewhere, we let the first task in the pipeline read it straight from
the source of truth the developers already maintain. Their build tool config
IS the pipeline config.

So the pipeline’s first real task inspects the pom.xml, extracts the declared Java version, and emits it as a Tekton result that downstream tasks consume. A build step then selects the matching Red Hat Hardened Imagehi/openjdk:17-runtime, hi/openjdk:21-runtime, hi/openjdk:25-runtime—based on what the project asked for. These are Red Hat’s distroless, security-scanned image line, which is exactly what you want feeding a supply chain you’re about to sign and attest. One pipeline, three Java versions, zero per-version copies. The variation lives in the project, not in the platform.

The same pattern extends cleanly to a second language. A Python team using Poetry declares its interpreter constraint in pyproject.toml. A python-build pipeline inspects that file, selects the matching hardened Python image, runs poetry install against the lockfile for reproducibility, and runs the tests. The two pipelines share their language-agnostic bones—the clone task, the image-build task—and differ only in the language-specific middle.

The Assemble-Only Container Contract

Every project ships a Containerfile at its root, and that’s what produces the deliverable image. But there’s a design decision hiding here that’s worth making deliberately: does the Containerfile build the application from source, or does it merely assemble an already-built artifact?

We choose assemble-only. The pipeline builds and tests the artifact—the jar, or the Python wheel—and the Containerfile does nothing but copy that finished artifact into a runtime base image. This keeps the build and test signal in Tekton, where we can see it, cache it, and gate on it, rather than burying it inside opaque Dockerfile stages. It’s the multi-stage build benefit, except the “build stage” is your pipeline.

This only works if you impose a convention, and the convention is refreshingly simple because it just follows each build tool’s natural output directory. Maven already drops the jar in target/. Poetry drops the wheel in dist/. So the contract is: Java artifacts land in target/, Python wheels land in dist/, and Containerfiles copy from those paths.

FROM registry.access.redhat.com/hi/openjdk:21-runtime
COPY target/*.jar /deployments/app.jar

The build task and the container-build task share a workspace, so when Buildah runs with the repo root as its context, target/ is sitting right there with the freshly built jar inside it. We use Buildah for the build itself—it’s the daemonless, rootless, CNCF-aligned choice on OpenShift, and it reads a Containerfile natively without any renaming games. The image gets tagged with the commit SHA from the git event, giving us an immutable, precise reference that plays beautifully with GitOps promotion downstream.

One nice consequence of assemble-only: the FROM line in the Containerfile is where a team legitimately expresses its runtime needs—which base image, which OS packages, which non-root user. That’s genuinely application-specific and should live with the app. The pipeline owns how the artifact is built; the team owns what it runs on.

Introducing Changes Without Breaking Everyone

Here’s the double-edged sword of the cluster resolver. Because the pipeline is resolved fresh on every run, “edit once, everyone benefits” is also “edit once, break everyone.” A bad merge to java-build breaks all ten teams on their very next push, and they had no say in the matter. The teams gave up the ability to choose when they take a change, so we have to reintroduce that control at the platform layer.

The first line of defense is to understand what actually breaks a build. Most pipeline edits are internal—bumping a task image, adding a step, tightening a resource limit. The team’s stub says nothing about these, so they propagate silently and safely. The dangerous category is contract changes: adding a required parameter with no default, renaming a workspace, changing a result name. These invalidate every existing stub the instant they merge. The discipline is to design changes the way you’d evolve a public API—add new parameters with sensible defaults, never rename in place, keep workspace names stable—so the overwhelming majority of your changes stay in the safe bucket.

For the changes you genuinely can’t prove safe by inspection, you need a canary. Designate a pipeline-canary namespace that runs the shared pipelines against a representative set of test repositories, and wire it as an earlier sync-wave than the production platform-pipelines namespace in your GitOps setup. When platform engineering merges a pipeline change, ArgoCD syncs the canary first, runs a real build against real test repos, and only advances to update the production pipeline if that build passes. A broken change turns the canary red, the sync-wave stalls, and the ten teams keep building against the last known-good definition, blissfully unaware.

A subtlety that will bite you: ArgoCD doesn't natively understand whether a
PipelineRun succeeded or failed. Left to its own devices, it marks the run
"synced" the instant it's created and cheerfully advances to production
before your canary has even finished building. You need either a custom Lua
health check for tekton.dev/PipelineRun or a Job wrapper whose exit code
ArgoCD does understand. Without one of these, your canary gate is decorative.

For the rare, genuinely breaking change—a new required parameter, a restructured workspace layout—fall back to explicit versioning. Publish the new behavior as java-build-v2 alongside the stable java-build, let willing teams opt in by changing one line in their stub on their own schedule, and promote v2 into the stable name once it’s proven across enough real builds. It costs a one-line team-side edit, but in exchange nobody gets a cliff.

Per-Team Visibility in Red Hat Developer Hub

We use Red Hat Developer Hub—the Backstage-based internal developer portal—as the front door for our teams. Which raises an important question: if all ten teams share one pipeline and one event listener model, will each team still only see their own pipeline runs in the portal?

The answer depends entirely on architecture, and happily, ours already solves it. Visibility in the RHDH Tekton plugin is driven by a label selector on the catalog entity, matched against the backstage.io/kubernetes-id label on the PipelineRun objects. Remember that our generic stub stamps exactly this label, derived from the repo name: backstage.io/kubernetes-id: "{{ repo_name }}". Each team’s catalog-info.yaml sets its matching value, and the Kubernetes plugin backend surfaces only the runs whose labels match.

Because our builds already execute in per-team namespaces—thanks to PAC keying off the Repository CR—this isn’t merely UI filtering. It’s real isolation backed by namespace boundaries and RBAC. Team A’s portal view shows Team A’s runs because those runs live in Team A’s namespace and carry Team A’s label. There’s no shared build farm where everyone’s runs pile up in one pool and you hope the filter holds.

Securing the Supply Chain

A pipeline that builds and pushes an image is table stakes in 2026. What separates a demo from a production platform is everything that happens around the build: generating a software bill of materials, signing the image, attesting its provenance, and refusing to promote anything that fails policy. Red Hat packages this as the Red Hat Trusted Software Supply Chain, and the pieces map onto what we’ve built with almost suspicious neatness.

Red Hat Trusted Artifact Signer (RHTAS) is the signing layer. Built on the open source Sigstore project, it stands up your own keyless certificate authority—Fulcio, Rekor, and the supporting cast—inside the cluster, so your pipeline signs images against your trust root rather than the public Sigstore instance. The signing itself is done with cosign, and identity is established through OIDC, which means there’s no long-lived signing key for someone to steal.

Red Hat Trusted Profile Analyzer (RHTPA) is the SBOM and vulnerability layer. Your pipeline generates a software bill of materials during the build—capturing both the application dependencies and the runtime base image layers—and publishes it to RHTPA, which becomes the queryable system of record for what’s actually inside your images and where the vulnerabilities live.

The mechanism that ties signing and attestation to your pipeline is Tekton Chains, and this is the architectural detail worth internalizing. Chains isn’t a task you add to your pipeline graph. It’s a controller that observes completed PipelineRun objects and, out of band, signs the images they produced and records SLSA provenance attestations—the .sig and .att artifacts—pushing them alongside the image in your registry. Your java-build and python-build pipelines stay focused on building and testing. Chains handles provenance automatically, uniformly, for both languages, with no per-pipeline changes.

Finally, Enterprise Contract—the upstream project is now called Conforma—is the policy gate, and it slots directly into the promotion story we built with the canary. It’s a policy-driven tool that validates a container image was signed and attested by a build system you trust, checking the signatures and confirming the attestation contents match what’s expected. When you promote from development to staging, or staging to production, Enterprise Contract runs its checks, and only a passing image is allowed through. The hand-rolled “don’t let a bad artifact reach teams” instinct from our canary design becomes a formal, cryptographically-backed policy decision.

A Word on Building Versus Adopting

If you’ve been reading closely, you may have noticed that we’ve been reconstructing something Red Hat already ships. The combination of Pipelines as Code, centrally-resolved Tekton pipelines, Tekton Chains for signing, Enterprise Contract for promotion gates, and Red Hat Developer Hub for golden-path templates is Red Hat Trusted Application Pipeline (RHTAP). It’s built on precisely the primitives we assembled by hand in this post.

That’s not wasted effort. Building it yourself is exactly what makes RHTAP’s opinionated defaults legible rather than magical—you understand why each piece exists because you’ve felt the problem it solves. When you’re ready for production, evaluate adopting RHTAP rather than maintaining your own reconstruction, and let your custom pieces—the pom-driven version selection, the assemble-only Containerfile convention, the GitOps onboarding—layer on top of the product’s foundation.

Summary

Standing up CI/CD for many teams is not about giving every team a pipeline; it’s about giving every team access to one pipeline they don’t have to own. By anchoring the whole design on the Tekton Cluster Resolver, we let platform engineering own a single canonical definition while teams reference it through a thin, generic stub—triggered per-namespace by Pipelines as Code, configured from the project’s own pom.xml or pyproject.toml, and producing a SHA-tagged image through an assemble-only Containerfile. We reintroduced the teams’ lost control over change through a canary namespace and API-versioning discipline, gave each team an isolated view of their runs in Red Hat Developer Hub, and secured the whole chain with SBOM generation, Sigstore-based signing through Trusted Artifact Signer, automatic attestation via Tekton Chains, and policy-gated promotion through Enterprise Contract. The result is a platform where teams maintain nothing, platform engineering changes everything from one place, and every image that reaches production is signed, attested, and accounted for.