Build an AI-Powered CI/CD Pipeline with GitHub Copilot

GitHub Copilot for DevOps & InfrastructureAcademy lesson 51Cluster 4 · Lesson 13 of 13Advanced19 min readVersion-sensitive
Published
Updated
Last technically verified
Build an AI-Powered CI/CD Pipeline with GitHub CopilotGitHub Copilot for DevOps & Infrastructure13Advanced/github-copilot/devops/ai-cicd-pipeline/

This is the capstone, and it assembles every technology in Cluster 4 into one repository: a FastAPI service, its tests, a container image, a Compose file for local work, Terraform for the registry it publishes to, Kubernetes manifests for where it runs, a CI workflow that validates all of it, and the Copilot instruction files that tell a model how to work in it.

Copilot can write every one of those artefacts, and does it well. The thesis of the whole cluster reduces to one sentence here:

Copilot assists with the pipeline. It is not the pipeline’s trust authority. The gates are.

Every gate below was run for real, on this machine, and the results are quoted. The deploy job was not, and the reason is stated rather than glossed.

The shape

What the pipeline validates, layer by layer
  1. CodeThe application, and the tests that say whether it works.
  2. ContainerOne image, built reproducibly, running as a non-root user.Docker
  3. Local environmentThe service plus its dependencies, on a laptop.Docker Compose
  4. OrchestrationScheduling, health, rollout and network policy across a cluster.Kubernetes
  5. InfrastructureThe cluster, the network and the managed services underneath it.TerraformOpenTofu
  6. ConfigurationThe state of the machines that are not disposable.AnsibleLinux
  7. CloudThe account, the identities and the bill.AWSAzureGoogle Cloud
  8. AutomationThe gates that decide whether any of the above is allowed to change.GitHub ActionsCI/CD

Each layer has a local, non-destructive check, and the pipeline runs all of them before anything reaches a deployment gate.

The pipeline
  1. Developer, with CopilotApplication code, tests, Dockerfile, Terraform, manifests, workflow — all assisted, none trusted.
  2. Repository instructionsThe model already knows the gates, the conventions and what it must never run.
  3. Pull requestThe unit of review. Nothing reaches main without one.
  4. testpytest. The only job that proves behaviour rather than shape.
  5. containerhadolint, docker build, then start the image and probe it.
  6. infrastructureterraform validate, kubeconform, checkov. Runs in parallel with container.
  7. Human reviewHuman judgementThe scan output and the plan attached to the pull request. This step is not automatable and not optional.
  8. Merge to mainHuman judgement
  9. deployHuman judgementGated on all three jobs, on the branch, and on a protected environment requiring an approval.

Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.

Two structural decisions in that graph are worth naming.

container and infrastructure run in parallel and neither depends on the other. They validate different things and a failure in one should not hide a failure in the other. Only deploy needs all of them.

test gates container. Building an image from code that fails its tests wastes a runner and produces an artefact nobody should use.

The repository

Practical example

copilot-ai-cicd — one repository, every gate

A complete pipeline where Copilot wrote every artefact and no artefact is trusted because of it.

Status
Tested implementation
Runtime
Docker Engine 29.7.2, hadolint 2.15.1, Terraform v1.15.9, kubeconform v0.8.0, Checkov 3.3.13, actionlint 1.7.12, yamllint 1.38.0, pytest 9.1.1
Command
pytest -q && hadolint Dockerfile && docker build . && docker compose config -q && terraform validate && kubeconform -strict && checkov -d infrastructure && actionlint
Result
7 passed; hadolint no findings; image built at 249MB and answered /healthz and /budget correctly; compose config exit 0; terraform validate Success; kubeconform 4 resources 4 valid; checkov terraform 7 passed 0 failed 1 skipped and kubernetes 89 passed 1 failed; actionlint no findings; yamllint clean.
Run on
August 21, 2026

Files

examples/cluster-4/ai-cicd-pipeline

copilot-ai-cicd/ ├── app/main.py FastAPI: /healthz and /budget ├── tests/test_app.py 7 tests ├── Dockerfile multi-stage, non-root uid 10001 ├── .dockerignore ├── compose.yaml local environment ├── requirements.txt ├── requirements-dev.txt ├── infrastructure/ │ ├── terraform/main.tf ECR repository, KMS key, lifecycle policy │ └── kubernetes/ │ ├── namespace.yaml Pod Security Admission: restricted │ ├── deployment.yaml probes, limits, securityContext │ ├── service.yaml ClusterIP │ └── networkpolicy.yaml default-deny └── .github/ ├── copilot-instructions.md the gates, and the six rules ├── instructions/ │ ├── docker.instructions.md │ ├── terraform.instructions.md │ ├── kubernetes.instructions.md │ └── actions.instructions.md └── workflows/ci.yml

The application, and why it is small

Seven tests over two endpoints. The application exists to give the pipeline something real to validate — a health endpoint the container job can probe, and a small piece of logic with boundary conditions worth testing.

@app.post("/budget", response_model=BudgetResponse)
def budget(request: BudgetRequest) -> BudgetResponse:
    """Overspend clamps to zero rather than returning a negative remainder."""
    return BudgetResponse(
        remaining_cents=max(0, request.limit_cents - request.spent_cents),
        exceeded=request.spent_cents > request.limit_cents,
    )

The tests cover the normal case, the clamp, the exact boundary — spending precisely the limit is not exceeding it — and two validation failures. That boundary test is the one worth having: it is the assertion that fails if someone changes > to >=, and it is exactly the case a generated test suite omits unless asked.

The digest, and why it is the point

  container:
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - name: Build the image
        id: build
        run: |
          docker build --iidfile image.id --tag "ci:${GITHUB_SHA}" .
          printf 'digest=%s\n' "$(cat image.id)" >> "$GITHUB_OUTPUT"

--iidfile writes the image ID, which is captured as a job output and consumed by deploy:

  deploy:
    needs: [test, container, infrastructure]
    steps:
      - name: Deployment gate
        env:
          IMAGE_DIGEST: ${{ needs.container.outputs.digest }}
        run: printf 'Would deploy image digest: %s\n' "$IMAGE_DIGEST"

This is the mechanism that makes the pipeline mean something. A workflow that builds myapp:latest and then deploys myapp:latest performs two separate resolutions of a moving pointer, and there is no guarantee they resolve to the same thing. Passing the digest makes “the image the tests ran against” and “the image being deployed” the same statement.

The Kubernetes manifests reference the image by digest for the same reason, and the Kubernetes lesson covers why a tag is insufficient there specifically.

The container job proves the image starts

- name: Verify the container starts and answers
  run: |
    docker run --rm -d -p 8000:8000 --name ci-check "ci:${GITHUB_SHA}"
    for _ in $(seq 1 30); do
      if curl -fsS http://127.0.0.1:8000/healthz > /dev/null; then break; fi
      sleep 1
    done
    curl -fsS http://127.0.0.1:8000/healthz
    docker stop ci-check

Eight seconds, and it catches the failure class where the image builds perfectly and the process exits immediately — a wrong entrypoint, a missing runtime dependency, a permission problem introduced by the non-root user. hadolint and docker build both pass on an image that cannot start.

The retry loop rather than a fixed sleep is deliberate: a fixed sleep is either too short, making the job flaky, or too long, making every run slower.

Observed output, gate by gate

$ pytest -q
7 passed

$ hadolint Dockerfile
(no findings)

$ docker build -t copilot-ai-cicd:local .
(success — 249MB)

$ curl -s localhost:8000/healthz
{"status":"ok","uptime_seconds":1.314,"version":"0.1.0"}

$ curl -s -X POST localhost:8000/budget \
    -d '{"limit_cents":10000,"spent_cents":12000}'
{"remaining_cents":0,"exceeded":true}

$ docker compose config -q
(exit 0)

$ terraform fmt -check -recursive && terraform validate
Success! The configuration is valid.

$ kubeconform -strict -summary infrastructure/kubernetes/
Summary: 4 resources found in 4 files - Valid: 4, Invalid: 0

$ checkov -d infrastructure --framework terraform
passed=7 failed=0 skipped=1

$ checkov -d infrastructure --framework kubernetes
passed=89 failed=1

$ actionlint .github/workflows/ci.yml
(no findings, exit 0)

The single Checkov failure is CKV_K8S_11: CPU limits should be set, which the Deployment deliberately does not satisfy — a CPU limit throttles rather than kills, and the reasoning is a comment in deployment.yaml. The disagreement is recorded rather than suppressed.

All four pinned action SHAs were verified against the GitHub API and each resolves to a real commit matching the tag in its trailing comment.

What was not run

The workflow itself. Nothing was pushed to GitHub. No run exists, and no screenshot of a green pipeline appears in this lesson, because there is no run to screenshot.

terraform plan and terraform apply. No AWS credentials exist here, and none should.

kubectl apply. There is no cluster. As the Kubernetes lesson documents, --dry-run=client is not offline either — it contacts the API server for the OpenAPI schema.

The deploy job. It is a gate containing a printf. No infrastructure exists, no image was published, and no cloud account was contacted.

Validation

The instructions file is the highest-leverage artefact

Everything else in this repository is code. .github/copilot-instructions.md is the thing that changes how the code gets written, and it is the file most teams never create.

The section that does the most work is not the style rules. It is this:

## Before considering any task complete

Run every gate that the change touches, and report the output:

    pytest -q
    hadolint Dockerfile
    docker build -t ci:local .
    docker compose config -q
    cd infrastructure/terraform && terraform fmt -check && terraform init -backend=false && terraform validate
    kubeconform -strict -summary infrastructure/kubernetes/
    checkov -d infrastructure
    actionlint .github/workflows/ci.yml

A task is not complete because the code looks right. It is complete when the
gates pass and you have said which ones you ran.

That paragraph converts agent mode from “generate and hope” into a loop with a termination condition the model can evaluate. It is the difference described in the pillar: every command in that list produces precise, structured error output, and fixing the error is usually mechanical, so the loop converges rather than drifting.

The six rules underneath it are the policy layer:

1. Format configuration before proposing it.
2. Run syntax validation for the file type you changed.
3. Run the relevant linter and report every finding, including ones you consider
   unimportant.
4. Never apply infrastructure. Propose; a human executes.
5. Never expose credentials.
6. Report potentially destructive changes explicitly — in the first line of your
   reply, not in a footnote.

The four path-specific files carry the per-technology rules, scoped with applyTo globs so a Dockerfile edit gets Dockerfile rules and nothing else. Each one is copied from its lesson in this cluster, with the glob narrowed to this repository’s layout:

docker.instructions.md      applyTo: "**/Dockerfile,**/Dockerfile.*,**/.dockerignore"
terraform.instructions.md   applyTo: "infrastructure/terraform/**/*.tf"
kubernetes.instructions.md  applyTo: "infrastructure/kubernetes/**/*.yaml"
actions.instructions.md     applyTo: ".github/workflows/**/*.yml,.github/workflows/**/*.yaml"

Building this with Copilot, in order

The order matters, because each artefact is context for the next.

1. The instructions file first. Before any code. It is the only artefact that improves every subsequent one, and writing it last means regenerating everything.

2. The application and its tests, from a description of required behaviour rather than from an implementation — see GitHub Copilot for Python for why tests generated by reading the code encode the code’s bugs.

3. The Dockerfile, with requirements.txt open.

4. The Compose file, with the Dockerfile open.

5. The Kubernetes manifests, with the Dockerfile open — the numeric uid has to match runAsUser, and a model that can see both will make them agree.

6. The Terraform.

7. The workflow last, with everything else present, so the validation commands it runs are the ones that exist.

Copilot promptThe workflow prompt, once everything else existsCopilot Chat, with the repository open

Write the CI workflow for this repository.

Read the gates listed in .github/copilot-instructions.md and create one job per logical group: tests, container, infrastructure.

Requirements:

  • permissions: contents: read at workflow level, widened per job only where a job needs it.
  • Every third-party action pinned to a full commit SHA with the version in a trailing comment.
  • The container job must capture the built image digest as a job output.
  • The deploy job needs all three, runs only on push to main, targets a protected environment, and requests id-token: write for OIDC — no stored cloud secret.
  • timeout-minutes on every job, concurrency on the workflow.

Then run actionlint and report every finding.

“Read the gates listed in the instructions file” is the clause that makes this work. The model is not inventing a pipeline; it is transcribing one that is already written down.

The infrastructure layers

The two infrastructure directories are small on purpose, and each demonstrates one decision that matters for a pipeline rather than for infrastructure in general.

Terraform: the registry, and immutable tags.

resource "aws_ecr_repository" "api" {
  name                 = var.project
  image_tag_mutability = "IMMUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }

  encryption_configuration {
    encryption_type = "KMS"
    kms_key         = aws_kms_key.ecr.arn
  }
}

IMMUTABLE is the pipeline-relevant setting. It makes it impossible to push a different image over an existing tag, which means the tag your deployment references cannot change underneath it. Combined with the digest passed forward from the container job, that closes the gap between what was tested and what runs from both directions.

scan_on_push puts vulnerability scanning in the registry rather than in the pipeline, which catches the case where an image sat in the registry for three months and a CVE was published in the meantime. A build-time scan cannot do that; only a re-scan can.

The variable that enforces the discipline:

variable "image_digest" {
  type = string
  validation {
    condition     = can(regex("^sha256:[0-9a-f]{64}$", var.image_digest))
    error_message = "image_digest must be a sha256 digest."
  }
}

Four lines making it impossible to deploy a mutable reference. This is the same pattern as the Cloud Run image validation in the Google Cloud lesson: where a scanner has no rule, a variable validation is the local substitute.

Kubernetes: the same manifests, with the digest substituted.

The Deployment carries everything the Kubernetes lesson argues for — probes, requests, a memory limit, a full securityContext, automountServiceAccountToken: false — and one addition specific to being in a pipeline: the image is a digest placeholder the deploy step substitutes.

# The digest is substituted by the pipeline from the image it built.
# A tag here would break the link between "what CI tested" and "what
# runs", which is the entire point of the pipeline.
image: ghcr.io/example/copilot-ai-cicd@sha256:0000…0000

The NetworkPolicy is there because Checkov’s CKV2_K8S_6 flagged its absence, and adding it was the right response rather than suppressing the finding. That is worth noting as a contrast with the CPU-limit disagreement: one finding produced a fix, the other produced a documented reason. Both are legitimate responses; an unexamined red line is not.

Local development

compose.yaml is the fourth artefact and the one that never runs in CI. It exists so the loop before the pull request is short — build, run, curl, edit — without pushing.

The property worth preserving is that the Compose file builds the same Dockerfile the pipeline builds. A repository where local development uses a different image from CI has two artefacts that drift, and the drift surfaces as “works on my machine” in its most literal form.

services:
  api:
    build:
      context: .
    image: copilot-ai-cicd/api:local
    ports:
      - "127.0.0.1:8000:8000"
    healthcheck:
      test: ["CMD", "python", "-c", "…urlopen('http://127.0.0.1:8000/healthz')…"]
    deploy:
      resources:
        limits:
          memory: 512M

The loopback binding and the memory limit are the same discipline as the Compose lesson, and the health check is the same one the container job runs in CI — so a change that breaks startup fails locally first.

The settings outside the workflow

A pipeline is not only its workflow file, and three of its most important controls live in repository settings where no generated code can create them. They are worth listing because a review of the workflow alone will miss all three.

Branch protection on main. Require the status checks — test, container, infrastructure — to pass, require a pull request, require review, and disallow force pushes. Without this, the pipeline is advisory: anyone can push directly to main and the deploy job runs on whatever they pushed.

Environment protection rules. environment: production in the workflow does nothing on its own. The required reviewers, the wait timer and the branch restriction are configured on the environment, in settings. This is deliberate — it means changing who can approve a deployment is an audited repository change rather than a line in a file that the same pull request could edit.

Actions permissions at repository or organisation level. The default GITHUB_TOKEN permissions, whether workflows may approve pull requests, and which third-party actions are allowed to run at all. An organisation-level allowlist is the systematic answer to the pinning problem in the Actions lesson — it constrains what can be used, rather than relying on every workflow being reviewed carefully.

Measuring whether the pipeline is working

Four questions worth asking periodically, none of which is about speed.

How often does a gate catch something? A gate that has never failed is either protecting against something that does not happen or is not actually running. Both are worth knowing. The container job’s start-and-probe step is the one most likely to be in the first category and most valuable when it is not.

How long does the feedback loop take? Not the pipeline’s total duration — the time from pushing to knowing something is wrong. A twenty-minute pipeline where the tests run first and fail in ninety seconds is better than a six-minute one where everything runs in parallel and the failure is buried.

Are approvals being read? The uncomfortable one. If deployment approvals are granted in under ten seconds, the gate is a formality. Attaching the plan and the scan summary to the pull request is what makes reading possible; asking for approval on an empty plan is what makes it pointless.

Is the instructions file current? It is the artefact that decays most quietly. A gate added to the workflow and not to copilot-instructions.md means an agent will keep declaring tasks complete without running it. Reviewing that file whenever the pipeline changes is a small habit with a disproportionate effect.

What the pipeline cannot tell you

A green pipeline establishes a specific and limited set of facts: the tests passed, the image builds and starts, the configuration parses and matches its schemas, the policy scanners found nothing they have rules for, and the workflow is well-formed.

It does not establish that the change is correct. Five things it says nothing about:

Whether the feature does what was asked. The tests assert what someone decided to assert.

Whether the architecture is right. GitHub documents that Copilot works from limited scope and cannot identify broader architectural issues. Neither can a linter.

Whether the infrastructure change is safe to apply. terraform validate passes on a configuration whose plan destroys a database. Only the plan says that, and only a person reads it.

Whether the scanner had rules. The empty-scan finding from the Google Cloud lesson is the sharpest version: Checkov returned nothing for a Cloud Run configuration because it has no policies for that resource type. Clean and empty are the same green tick.

Whether the change should exist. No tool answers this.

The deployment gate

The deploy job in this repository deploys nothing. It exists to demonstrate the shape, and the shape is five controls:

  deploy:
    needs: [test, container, infrastructure]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write

needs means every validation job passed. The if means it cannot run from a pull request, including one from a fork. environment: production is where the human approval and the deployment credentials live — required reviewers are configured on the environment, not in the workflow, so changing them is an audited repository setting rather than a file edit. concurrency with cancel-in-progress: false means two deploys queue rather than one killing the other partway through. And id-token: write scoped to this job means no other job’s token can mint a cloud credential.

The credential question deserves emphasis. A real deploy job needs cloud access, and the two options are a stored long-lived secret or OIDC. OIDC is better in every respect: the token is short-lived, there is nothing to rotate, and the trust policy on the cloud side names the repository and ref. The trust condition is the control — see AWS, Azure and Google Cloud for the three variants, all of which have the same failure mode when the subject condition is omitted.

Extending it

The repository is deliberately minimal. Four additions are worth making before using this shape for anything real, and each belongs in the pipeline rather than in a checklist.

Image scanning. hadolint checks the Dockerfile; it says nothing about the base image’s vulnerabilities. Trivy or docker scout in the container job, with a severity threshold that fails the build.

Dependency and secret scanning. Dependabot for the first, and a secret scanner in the pipeline for the second — because the Ansible lesson demonstrated that the config linters will not find a committed credential.

A terraform plan job with read-only OIDC, posting the plan as a pull request comment. This is the single highest-value addition: it puts the destroy count in front of the reviewer at review time rather than at deploy time.

A post-deployment smoke test, and an automatic rollback if it fails. A deploy that succeeds and leaves the service unhealthy is worse than one that fails.

Two things worth not adding: a step that automatically merges when the pipeline is green, and -auto-approve anywhere outside a job that consumes a plan a human approved.

Rollback

A deployment pipeline without a rollback path is a one-way door with a nice approval screen on it, and rollback is the part generated pipeline configuration omits most consistently — because nobody asks for it.

Three layers, and they roll back differently.

The application. Redeploying the previous image digest is the fastest and most reliable remediation available, and it works precisely because the digest was captured. kubectl rollout undo deployment/api does the same thing at the cluster level, using the previous ReplicaSet — which only exists if revisionHistoryLimit was not set to zero, as the Kubernetes lesson notes.

The infrastructure. Terraform has no undo. “Rolling back” means applying the previous configuration, which is a new change producing a new plan — and if the forward change replaced a resource, the reverse change replaces it again. This is why reading a plan for destroys matters more than any other single habit in this cluster: for infrastructure, prevention genuinely is the only cure.

The database. Outside the scope of this repository and the thing that most often makes a rollback impossible. A schema migration that dropped a column cannot be undone by deploying the previous image. The discipline that makes rollback possible is expand-and-contract: add the new column, deploy code that writes both, migrate, deploy code that reads the new one, and only then drop the old — with each step independently reversible.

Copilot promptAsking for the rollback with the changeCopilot Chat

Here is the change I am about to make.

For each layer it touches — application, infrastructure, database — tell me how to reverse it, how long the reversal takes, and whether anything about it is irreversible.

If any part cannot be reversed, say so first and explain what would make it reversible.

Asking for this at design time rather than during an incident is the entire point. A model enumerating irreversible steps is doing a reading task it does well, and the answer changes the design while changing the design is still cheap.

Where Copilot fits, task by task

A summary of the whole cluster in the form of a division of labour.

TaskCopilotYou
Application code and testsDrafts bothDecides what the tests assert
DockerfileWrites itChecks the user, the stages, the secrets
Compose fileWrites itChecks what is published and to which interface
Kubernetes manifestsWrites themChecks probes, limits, securityContext, exposure
TerraformWrites itReads the plan
WorkflowWrites itChecks permissions, pinning, triggers, gates
Instruction filesDrafts themDecides the rules
Validation commandsRuns them, in agent modeDecides what “done” means
Explaining a failureVery good at itConfirms against the evidence
Rollback planEnumerates the stepsDecides whether the risk is acceptable
Approving a deploymentNeverAlways

The last row is not a formality. Everything above it is mechanical enough to delegate, verify or automate. The decision to change production is the one thing in this pipeline that has no mechanical substitute, and the purpose of every gate above it is to make that decision cheap to take well.

Cluster 4 complete

Thirteen lessons, twelve technologies, and one argument made from evidence collected while building the examples:

  • hadolint reported six findings on a Dockerfile and never mentioned that it ran as root.
  • kubeconform passed a manifest that Checkov then failed twenty-one times.
  • ansible-lint reported nineteen findings on a playbook and never mentioned the plaintext password.
  • ShellCheck reported six findings on a script containing rm -rf inside a health check.
  • actionlint caught a script-injection vector and missed a missing permissions: block.
  • Checkov returned an empty report on a Cloud Run configuration, because it had no rules to run.
  • A health-check script passed ShellCheck, ran under set -euo pipefail, and silently checked nothing — because a failure inside a process substitution is invisible to set -e.

Each of those is a tool doing its job correctly and a reader drawing the wrong conclusion from a green result. That is the whole cluster:

Copilot can generate infrastructure. Validation determines whether that infrastructure is trustworthy — and the validation you have is narrower than you think.

Next: Cluster 5

The Academy continues with Cluster 5 — GitHub Copilot CLI: the terminal agent in depth, including sessions, permissions and sandboxing, plan and autopilot modes, worktrees, delegation, and programmatic use in CI. It is the natural continuation of this cluster, because the question it answers is the one this pipeline raises — what happens when the agent runs the commands itself, and what has to be true before you let it.

Three lessons here are the direct preparation: GitHub Copilot for Linux for the command discipline, GitHub Copilot for GitHub Actions for the automation surface, and this one for the gates.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources