Build a Secure GitOps Image Pipeline

Build a scanned pipeline that publishes immutable ECR images to GitOps.

Introduction

30 Second Summary

A release can look ready while the team still cannot prove which exact version is about to ship. Reused labels make that uncertainty worse because the same name can point to different software over time.

In this project, you will build a GitHub Actions pipeline that turns each Catalog commit into a tested container image. Trivy gates publication to immutable Amazon ECR storage before the pipeline writes the exact image reference to a separate GitOps repository.

What You'll Build

You'll watch one Catalog commit travel from passing tests through a Trivy gate into Amazon ECR under its commit SHA, ending with the separate deployment repository pointing to that exact artifact.

By the end of this project, you'll have:

  • A passing Catalog pipeline where the GitHub Actions log shows catalog tests passed before the built container prints the Catalog JSON.
  • A pre-publish security gate where Trivy stops fixable critical vulnerabilities before cloud authentication or image publication.
  • A retry-safe GitOps handoff with an Amazon ECR image tagged by commit SHA. A separate deployment repository records that exact reference without cluster credentials. Re-running the same commit reuses the image instead of overwriting it.
  • Secret Mission: A second Trivy gate that catches high and critical Dockerfile configuration findings before the image build.

Are there any prerequisites?

You need GitHub and AWS accounts with permission to create the project resources. You also need local Linux tooling such as Git and OpenSSH.

Before We Start

Before hands-on work begins, this step locks in your delivery boundary: the Catalog pipeline publishes a tested, scanned, immutable container image to Amazon ECR under its commit SHA. It records that image in a separate GitOps repository through a Git commit, keeping direct cluster access outside CI.

Set Up the Two-Repository Workspace

A secure GitOps handoff needs a visible boundary between application source and deployment configuration. Two repositories make that responsibility clear before automation enters the picture.

In this step, you will verify the tools that support the pipeline. You will also create the two GitHub repositories that hold each side of the handoff.

In this step, get ready to:
  • Verify the local command-line prerequisites.
  • Create separate source and deployment repositories.
  • Add the starter Catalog and deployment files.
Verify your local tools and AWS identity

Git records each source change that triggers the pipeline. OpenSSH provides the key tooling used for the repository-scoped handoff later in the project.

  • Open a terminal in your Linux environment.
  • Check your installed Git version by running this command:
git --version

What does this command check?

The command asks Git to print its installed version. A version line confirms that the executable is available from your terminal.

You should see a line containing a Git version number.

  • Check that the OpenSSH key generator is available by running this command:
command -v ssh-keygen

What does this command check?

The shell searches for the key generator executable. A printed path confirms that your environment can create the deploy key used later.

You should see the executable path for the key generator.

Git or OpenSSH missing?

  • Install the missing package with the package manager provided by your Linux distribution.
  • Restart your terminal after the installation.
  • Still stuck? Help me verify Git and OpenSSH on Linux.

AWS CLI v2 can use an authenticated local session to create project resources. AWS CloudShell provides a browser-based fallback with AWS CLI v2 already installed.

  • Check your local AWS CLI version by running this command:
aws --version

What does this command check?

The command prints the installed AWS CLI release. This project requires the major version shown as v2.

✔️ I see AWS CLI v2

Your local AWS session has the required command-line tool. The next check confirms which AWS identity the project uses.

  • Verify the active AWS identity by running this command:
aws sts get-caller-identity

What does this command show?

AWS returns details about the authenticated caller. This is the identity that creates the project resources in later steps.

You should see an AWS account identifier plus the active caller ARN.

AWS identity unavailable?

  • Confirm that your local AWS session is authenticated.
  • Use AWS CloudShell from the next tab when local authentication is unavailable.
  • Still stuck? Help me verify my authenticated AWS CLI session.

ⓧ I see an older version

Your local AWS CLI needs the current major version before it can support this project path.

  • Install AWS CLI v2 for your current Linux user by running this command:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash

What does this command do?

The command downloads the official Linux installation script over HTTPS. It passes that script to the shell for a current-user installation.

  • Verify the updated installation by running this command:
aws --version

What should change?

The output should now identify AWS CLI v2. This confirms that your terminal resolves the required installation.

  • Verify the active AWS identity by running this command:
aws sts get-caller-identity

What does this command show?

AWS returns the account and caller represented by your authenticated session. That caller becomes the resource-creation identity for this project.

You should see an AWS account identifier plus the active caller ARN.

Still seeing the older version?

  • Restart your terminal so it reloads its command search path.
  • Use AWS CloudShell when your Linux environment continues to resolve the older installation.
  • Still stuck? Help me resolve the AWS CLI version selected by my shell.

ⓧ Command not found

AWS CloudShell gives you a pre-authenticated browser shell without local credential configuration. It also includes AWS CLI v2.

  • Launch AWS CloudShell from your authenticated AWS Management Console session.
  • Verify the included AWS CLI version by running this command:
aws --version

What should this confirm?

CloudShell includes AWS CLI v2. The output confirms that the browser shell is ready for the AWS commands used in this project.

  • Verify the active CloudShell identity by running this command:
aws sts get-caller-identity

What does this command show?

CloudShell uses the identity from your AWS Management Console session. The response identifies the caller that creates the project resources.

You should see an AWS account identifier plus the active caller ARN.

CloudShell unavailable?

  • Confirm that your AWS Management Console session is still active.
  • Ask your AWS administrator whether your identity can use CloudShell.
  • Still stuck? Help me choose between local AWS CLI v2 and AWS CloudShell.
Create the two GitHub repositories

The source repository owns the Catalog service and its pipeline. The GitOps repository owns the deployment configuration that records the desired image.

GitHub uses your configured default branch name when the first file is committed to a new repository. Setting that name now ensures both repositories begin on main.

This setting sits inside your account preferences, so it can be easy to miss.

  • Select your profile picture in the upper-right corner of GitHub.
  • Select Settings.
  • Select Repositories under the code planning section.
  • Enter main as the default branch name for new repositories.
  • Click Update.

Why use two repositories?

The source repository can build and publish an image. The deployment repository can record which immutable image the environment should run.

This boundary lets delivery happen through a Git commit without giving the source pipeline access to a cluster.

  • Select the create menu in the upper-right corner of GitHub.
  • Click New repository.
  • Select the account that should own the source repository from the Owner menu.
  • Enter orderflow-catalog in the Repository name field.
  • Choose the repository visibility that matches how you want to share this project.
  • Leave the optional initialization files unselected.
  • Click Create repository.

You should see the empty orderflow-catalog repository page.

Your source boundary is ready

The source repository now has its own history and permissions. It becomes the only home for the Catalog service and its CI workflow.

  • Select the create menu in the upper-right corner of GitHub.
  • Click New repository.
  • Select the account that should own the deployment repository from the Owner menu.
  • Enter orderflow-gitops in the Repository name field.
  • Choose the repository visibility that matches the source repository.
  • Leave the optional initialization files unselected.
  • Click Create repository.

You should see the empty orderflow-gitops repository page.

Repository name unavailable?

  • Confirm that the repository is being created under the intended owner.
  • Check whether a repository with the same name already exists under that owner.
  • Still stuck? Help me create the two project repositories on GitHub.
Add the starter project files

The Catalog service stays intentionally small so the project can focus on its pipeline. Its shell script prints predictable JSON before keeping a future Docker container alive.

  • Return to the empty orderflow-catalog repository.
  • Select the file creation link on the empty repository page.
  • Enter catalog.sh in the file name field.
  • Add the Catalog service by pasting this code into the editor:
#!/bin/sh
set -eu

print_catalog() {
  printf '%s\n' '{"service":"catalog","status":"ok","items":[{"sku":"NW-101","name":"Keyboard"},{"sku":"NW-102","name":"Mouse"}]}'
}

print_catalog

if [ "${1:-}" = "--once" ]; then
  exit 0
fi

trap 'exit 0' TERM INT
while :; do
  sleep 3600 &
  wait "$!"
done

What does this script do?

  • The print_catalog() function emits one predictable Catalog response for tests and container runs.
  • The --once argument lets the script exit after printing its response.
  • The final loop keeps the default container process alive until it receives a termination signal.
  • Click Commit changes.
  • Keep the option to commit directly to the main branch.
  • Click Commit changes in the confirmation dialog.

You should see catalog.sh in the repository on the main branch.

Catalog script missing?

  • Confirm that the file name is exactly catalog.sh.
  • Check the repository branch selector for main.
  • Still stuck? Help me add the Catalog script through the GitHub editor.

The test script checks the two fields that define a healthy Catalog response. A deterministic response makes the first pipeline result easy to recognize.

  • Select the file creation menu above the orderflow-catalog file list.
  • Click Create new file.
  • Enter tests/catalog_test.sh in the file name field.
  • Add the Catalog checks by pasting this code into the editor:
#!/bin/sh
set -eu

output="$(sh ./catalog.sh --once)"

case "$output" in
  *'"service":"catalog"'*) ;;
  *)
    printf '%s\n' 'expected the Catalog service name' >&2
    exit 1
    ;;
esac

case "$output" in
  *'"status":"ok"'*) ;;
  *)
    printf '%s\n' 'expected an ok status' >&2
    exit 1
    ;;
esac

printf '%s\n' 'catalog tests passed'

What do these checks prove?

  • The script captures the one-time Catalog response in output.
  • The first case checks that the response names the Catalog service.
  • The second case checks that the service reports a healthy status.
  • The final message gives the workflow a visible passing result.
  • Click Commit changes.
  • Keep the option to commit directly to the main branch.
  • Click Commit changes in the confirmation dialog.

You should see a tests folder containing catalog_test.sh.

Test file in the wrong location?

  • Confirm that the file path is exactly tests/catalog_test.sh.
  • Delete any duplicate file created at the top level of the repository.
  • Still stuck? Help me place the Catalog test in its tests folder.

The Dockerfile packages the same script into a small image. It runs the process as a non-root numeric user.

  • Select the file creation menu above the orderflow-catalog file list.
  • Click Create new file.
  • Enter Dockerfile in the file name field.
  • Add the image definition by pasting this code into the editor:
FROM alpine:3.24.1
WORKDIR /app
COPY catalog.sh .
USER 65532:65532
ENTRYPOINT ["/bin/sh", "/app/catalog.sh"]

What does this Dockerfile define?

  • The base image provides the shell needed to run the Catalog script.
  • The working directory gives the container a predictable location for application files.
  • The numeric user keeps the Catalog process away from root privileges.
  • The entry point starts catalog.sh whenever the container runs.
  • Click Commit changes.
  • Keep the option to commit directly to the main branch.
  • Click Commit changes in the confirmation dialog.

You should see Dockerfile beside catalog.sh on the source repository page.

Dockerfile not recognized?

  • Confirm that the filename is exactly Dockerfile with no extension.
  • Check that each instruction appears on its own line.
  • Still stuck? Help me verify my Catalog Dockerfile.

The deployment manifest gives the GitOps repository an initial desired state. Its Kubernetes image field starts with a bootstrap value that the pipeline replaces later.

  • Switch to the empty orderflow-gitops repository.
  • Select the file creation link on the empty repository page.
  • Enter apps/catalog/deployment.yaml in the file name field.
  • Add the Catalog deployment by pasting this configuration into the editor:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog
spec:
  replicas: 1
  selector:
    matchLabels:
      app: catalog
  template:
    metadata:
      labels:
        app: catalog
    spec:
      containers:
        - name: catalog
          image: replace-me/orderflow-catalog:bootstrap

What does this manifest describe?

  • The Deployment describes one Catalog replica.
  • Matching labels connect the Deployment selector to its pod template.
  • The single container holds the image reference that the pipeline updates through Git.
  • Click Commit changes.
  • Keep the option to commit directly to the main branch.
  • Click Commit changes in the confirmation dialog.

You should see an apps folder in orderflow-gitops on the main branch.

Deployment file missing?

  • Confirm that you committed the file in orderflow-gitops.
  • Check that the full path is apps/catalog/deployment.yaml.
  • Still stuck? Help me add the deployment manifest to the GitOps repository.

✔️ Awesome, I've got everything!

Great work. Your source files and deployment configuration now live on opposite sides of the GitOps boundary.

ⓧ I'd like to double check the full code

  • Compare your catalog.sh file with this reference:
#!/bin/sh
set -eu

print_catalog() {
  printf '%s\n' '{"service":"catalog","status":"ok","items":[{"sku":"NW-101","name":"Keyboard"},{"sku":"NW-102","name":"Mouse"}]}'
}

print_catalog

if [ "${1:-}" = "--once" ]; then
  exit 0
fi

trap 'exit 0' TERM INT
while :; do
  sleep 3600 &
  wait "$!"
done

Catalog script checkpoint

This is the complete starter service for this step. It prints the bootstrap Catalog response used by the first tests.

  • Compare your tests/catalog_test.sh file with this reference:
#!/bin/sh
set -eu

output="$(sh ./catalog.sh --once)"

case "$output" in
  *'"service":"catalog"'*) ;;
  *)
    printf '%s\n' 'expected the Catalog service name' >&2
    exit 1
    ;;
esac

case "$output" in
  *'"status":"ok"'*) ;;
  *)
    printf '%s\n' 'expected an ok status' >&2
    exit 1
    ;;
esac

printf '%s\n' 'catalog tests passed'

Catalog test checkpoint

This is the complete starter test for this step. It checks the service name plus the healthy status before printing its passing result.

  • Compare your Dockerfile with this reference:
FROM alpine:3.24.1
WORKDIR /app
COPY catalog.sh .
USER 65532:65532
ENTRYPOINT ["/bin/sh", "/app/catalog.sh"]

Dockerfile checkpoint

This is the complete starter image definition for this step. It packages the Catalog script with its working directory and runtime identity.

  • Compare your apps/catalog/deployment.yaml file with this reference:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog
spec:
  replicas: 1
  selector:
    matchLabels:
      app: catalog
  template:
    metadata:
      labels:
        app: catalog
    spec:
      containers:
        - name: catalog
          image: replace-me/orderflow-catalog:bootstrap

Deployment checkpoint

This is the complete bootstrap deployment configuration for this step. Its image field gives the later GitOps update a single target.

Before you check, do you expect each repository to show a different set of files on main?

  • Return to the orderflow-catalog repository.
  • Confirm that its branch selector shows main.
  • Confirm that the file list contains catalog.sh plus Dockerfile.
  • Open the tests folder.
  • Confirm that the folder contains catalog_test.sh.
  • Switch to the orderflow-gitops repository.
  • Open the apps/catalog path.
  • Confirm that deployment.yaml contains the bootstrap image value.

You should see the service files only in orderflow-catalog. You should see the deployment manifest only in orderflow-gitops.

Your two-repository workspace is ready. Next, you will turn the source repository into a visible test and container build in GitHub Actions.

Test and Build the First Catalog Image

Your separate source and GitOps repositories now make the delivery boundary visible.

Before cloud credentials enter the pipeline, the Catalog service needs a visible baseline.

GitHub Actions will run the Catalog tests.

Docker will build the service into an image. A container run will print the Catalog JSON.

In this step, get ready to:
  • Add a workflow that prints the immutable OIDC subject.
  • Prove the Catalog image runs inside GitHub Actions.
  • Capture the repository identity for the IAM trust policy.
Capture the immutable OIDC subject

OIDC lets GitHub Actions present a repository identity to AWS. The IAM trust policy in the next step needs that identity exactly.

The current subject format includes the owner ID. It also includes the repository ID.

  • Create .github/workflows/show-oidc-subject.yml through the file explorer for your local orderflow-catalog repository.
  • Define the manual identity workflow by pasting this content:
name: Show OIDC subject

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  show:
    runs-on: ubuntu-24.04
    steps:
      - name: Print immutable subject for main
        shell: bash
        run: |
          repository_name="${GITHUB_REPOSITORY#*/}"
          printf 'repo:%s@%s/%s@%s:ref:refs/heads/main\n' \
            "$GITHUB_REPOSITORY_OWNER" \
            "$GITHUB_REPOSITORY_OWNER_ID" \
            "$repository_name" \
            "$GITHUB_REPOSITORY_ID"

How the identity workflow works

  • The workflow_dispatch trigger makes this a manual workflow.
  • The repository_name value removes the owner prefix from the repository name.
  • The GITHUB_REPOSITORY_OWNER_ID value identifies the repository owner.
  • The GITHUB_REPOSITORY_ID value identifies this specific repository.
  • The final printf assembles one branch-specific subject from those values.
  • Save .github/workflows/show-oidc-subject.yml.
  • Confirm your editor's file explorer lists show-oidc-subject.yml inside .github/workflows.

Identity workflow not saved?

  • Check that the file path is .github/workflows/show-oidc-subject.yml inside orderflow-catalog.
  • Check the YAML indentation under jobs and steps.
  • Still stuck? Help me check my OIDC subject workflow.

✔️ Awesome, I've got everything!

The identity workflow is saved in the source repository.

ⓧ I'd like to double check the full code

  • Compare .github/workflows/show-oidc-subject.yml with this complete file:
name: Show OIDC subject

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  show:
    runs-on: ubuntu-24.04
    steps:
      - name: Print immutable subject for main
        shell: bash
        run: |
          repository_name="${GITHUB_REPOSITORY#*/}"
          printf 'repo:%s@%s/%s@%s:ref:refs/heads/main\n' \
            "$GITHUB_REPOSITORY_OWNER" \
            "$GITHUB_REPOSITORY_OWNER_ID" \
            "$repository_name" \
            "$GITHUB_REPOSITORY_ID"

What to compare

Check the indentation beneath run: |. Confirm all four repository identity values appear in the printf arguments.

Build the first Catalog workflow

A continuous integration workflow turns every push into repeatable evidence. This first version proves the service before any AWS authentication is added.

  • Create .github/workflows/catalog-ci.yml through the file explorer for your local orderflow-catalog repository.
  • Define the test and image workflow by pasting this content:
name: Catalog image pipeline

on:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-24.04
    steps:
      - name: Check out Catalog source
        uses: actions/checkout@v7.0.1
      - name: Run Catalog tests
        shell: bash
        run: sh tests/catalog_test.sh
      - name: Build Catalog image
        shell: bash
        run: docker build --tag "catalog:${GITHUB_SHA}" .
      - name: Run Catalog image
        shell: bash
        run: docker run --rm "catalog:${GITHUB_SHA}" --once

How the Catalog workflow works

  • The push trigger limits automatic runs to commits on main.
  • The contents: read permission gives the checkout step read access to the source.
  • The actions/checkout@v7.0.1 step places the Catalog files on the hosted runner.
  • The test step runs tests/catalog_test.sh.
  • The build step tags the local image with GITHUB_SHA.
  • The final step starts the image with --once so the container prints its JSON before exiting.
  • Save .github/workflows/catalog-ci.yml.
  • Confirm your editor's file explorer lists catalog-ci.yml beside show-oidc-subject.yml.

Catalog workflow not recognized?

  • Confirm the file is inside .github/workflows.
  • Check that each step aligns beneath steps.
  • Still stuck? Help me check my Catalog workflow YAML.

✔️ Awesome, I've got everything!

Both workflow files are ready for the first push.

ⓧ I'd like to double check the full code

  • Compare .github/workflows/catalog-ci.yml with this complete file:
name: Catalog image pipeline

on:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-24.04
    steps:
      - name: Check out Catalog source
        uses: actions/checkout@v7.0.1
      - name: Run Catalog tests
        shell: bash
        run: sh tests/catalog_test.sh
      - name: Build Catalog image
        shell: bash
        run: docker build --tag "catalog:${GITHUB_SHA}" .
      - name: Run Catalog image
        shell: bash
        run: docker run --rm "catalog:${GITHUB_SHA}" --once

What to compare

Confirm the workflow has four steps. The final two steps must use the same GITHUB_SHA image tag.

Push and inspect the proof

A push to main now starts the Catalog workflow. Its log becomes the evidence that the test and container agree.

  • Stage both workflow files through the source-control view in your local Git client.
  • Commit the staged files with the message Add Catalog CI workflows.

Before you push, which proof do you expect the workflow to produce first?

  • Push the committed workflows to main by running this command:
git push origin main

What does this command do?

The command sends your local main commit to origin. That push triggers the workflow you just added.

  • Select Actions in the orderflow-catalog repository on GitHub.
  • Open the newest Catalog image pipeline run.
  • Expand Run Catalog tests in the workflow log.
  • Expand Run Catalog image in the workflow log.

You'll see catalog tests passed in the test log. The container log prints {"service":"catalog","status":"ok","items":[{"sku":"NW-101","name":"Keyboard"},{"sku":"NW-102","name":"Mouse"}]}.

That green run is your first CI baseline. The tested source now becomes a runnable container.

Catalog workflow failed?

  • Check that tests/catalog_test.sh still matches the starter test file.
  • Check that Dockerfile still copies catalog.sh into the image.
  • Still stuck? Help me diagnose my failed Catalog workflow.

The image workflow proves the build works. The identity workflow now captures the exact subject that AWS will trust.

  • Select Show OIDC subject from the left sidebar on the Actions page.
  • Click Run workflow.
  • Select main from the Branch menu.

Before you start it, which repository identity values do you expect the subject to contain?

  • Click Run workflow to start the identity workflow.
  • Open the completed Show OIDC subject run.
  • Expand Print immutable subject for main.
  • Copy the printed subject here: your immutable GitHub OIDC subject.

The saved line starts with repo:. It ends with :ref:refs/heads/main.

Run workflow button missing?

  • Confirm show-oidc-subject.yml exists on the main branch.
  • Confirm the workflow contains the workflow_dispatch trigger.
  • Still stuck? Help me run the OIDC subject workflow.

Your pipeline now tests the Catalog service before building a runnable image. Next, you'll connect that pipeline to immutable storage and experience the problem with a reusable image tag.

Demonstrate Immutable Tag Protection

Your GitHub Actions pipeline already proves that each source commit passes its tests. It also builds a runnable Catalog image.

This step tests whether the familiar latest tag can safely identify changed builds in an immutable Amazon ECR repository. Short-lived OIDC credentials give the workflow access without storing AWS keys in GitHub.

In this step, get ready to:
  • Create an immutable private ECR repository.
  • Grant the source workflow repository-scoped image push access.
  • Publish two different builds with the same tag to observe the protection.
Provision immutable ECR and IAM access

The workflow needs an image destination before it can publish anything. Tag immutability makes the first published value permanent for that tag.

An AWS Identity and Access Management role limits the workflow to pushing images into orderflow-catalog. Its trust policy also restricts authentication to the saved main-branch subject.

  • Return to the authenticated terminal you used for AWS earlier.
  • Confirm the active AWS identity by running:
aws sts get-caller-identity

You should see the AWS account details for the identity that creates the repository plus the IAM role.

AWS identity not returned?

  • Launch AWS CloudShell from your signed-in AWS console if your local terminal has no authenticated session.
  • Run the identity command again inside CloudShell.
  • Still stuck? Help me authenticate before creating the ECR and IAM resources.
  • Record the account ID from the command output here: your AWS account ID.
  • Record the immutable subject saved in the previous step here: your saved GitHub OIDC subject.
  • Create the immutable ECR repository by running:
aws ecr create-repository --repository-name orderflow-catalog --image-tag-mutability IMMUTABLE --region us-east-1

What does this command do?

The command creates the private orderflow-catalog repository in us-east-1. The IMMUTABLE setting prevents a published tag from being overwritten by different image content.

  • Confirm the returned repository details show tag mutability set to IMMUTABLE.

Repository creation failed?

  • Confirm that the active AWS identity can create ECR repositories.
  • Check whether an ECR repository named orderflow-catalog already exists in us-east-1.
  • Still stuck? Help me diagnose the ECR repository creation failure.
  • Switch back to the local orderflow-catalog folder from earlier.
  • Create an infra folder from your code editor's file sidebar.
  • Create trust-policy.json inside the infra folder.
  • Paste this trust policy into infra/trust-policy.json:
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::AWS_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"token.actions.githubusercontent.com:aud":"sts.amazonaws.com","token.actions.githubusercontent.com:sub":"SAVED_GITHUB_OIDC_SUBJECT"}}}]}

How does the trust policy work?

  • The federated principal identifies the GitHub OIDC provider in your AWS account.
  • The audience condition accepts tokens intended for sts.amazonaws.com.
  • The subject condition restricts the role to the exact owner ID plus repository ID combination saved earlier.
  • Replace AWS_ACCOUNT_ID with your AWS account ID.
  • Replace SAVED_GITHUB_OIDC_SUBJECT with your saved GitHub OIDC subject.
  • Save infra/trust-policy.json.
  • Confirm trust-policy.json appears inside the infra folder in your file sidebar.

Trust policy rejected?

  • Confirm that both placeholder values were replaced without removing their surrounding quotation marks.
  • Compare the saved subject with the output from the identity workflow character by character.
  • Still stuck? Help me validate my GitHub OIDC trust policy.
  • Create ecr-push-policy.json inside the same infra folder.
  • Paste this repository-scoped policy into infra/ecr-push-policy.json:
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["ecr:CompleteLayerUpload","ecr:UploadLayerPart","ecr:InitiateLayerUpload","ecr:BatchCheckLayerAvailability","ecr:PutImage","ecr:BatchGetImage"],"Resource":"arn:aws:ecr:us-east-1:AWS_ACCOUNT_ID:repository/orderflow-catalog"},{"Effect":"Allow","Action":"ecr:GetAuthorizationToken","Resource":"*"}]}

Why is this policy scoped?

The image upload actions apply only to the orderflow-catalog repository. Authorization token retrieval uses the wildcard resource required by ECR.

  • Replace AWS_ACCOUNT_ID with your AWS account ID.
  • Save infra/ecr-push-policy.json.
  • Confirm both JSON files appear inside the infra folder.

Policy file not accepted?

  • Confirm that the repository ARN contains your 12-digit AWS account ID.
  • Check that the repository name remains orderflow-catalog.
  • Still stuck? Help me validate the ECR push policy.

✔️ Awesome, I've got both policies

Both policy files are saved inside infra with your account-specific values.

ⓧ I'd like to double check the policies

Compare your two policy files with these complete references.

{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::AWS_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"token.actions.githubusercontent.com:aud":"sts.amazonaws.com","token.actions.githubusercontent.com:sub":"SAVED_GITHUB_OIDC_SUBJECT"}}}]}
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["ecr:CompleteLayerUpload","ecr:UploadLayerPart","ecr:InitiateLayerUpload","ecr:BatchCheckLayerAvailability","ecr:PutImage","ecr:BatchGetImage"],"Resource":"arn:aws:ecr:us-east-1:AWS_ACCOUNT_ID:repository/orderflow-catalog"},{"Effect":"Allow","Action":"ecr:GetAuthorizationToken","Resource":"*"}]}

AWS needs a GitHub OIDC provider before the role can trust a workflow token. The next command reuses that provider when it already exists.

  • Create or reuse the GitHub OIDC provider by running this from your authenticated AWS terminal:
AWS_ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
OIDC_PROVIDER_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com"

if aws iam get-open-id-connect-provider \
  --open-id-connect-provider-arn "$OIDC_PROVIDER_ARN" >/dev/null 2>&1; then
  printf '%s\n' 'Reusing the existing GitHub OIDC provider'
else
  aws iam create-open-id-connect-provider \
    --url https://token.actions.githubusercontent.com \
    --client-id-list sts.amazonaws.com
fi

What does this command do?

The first value derives your AWS account ID from the authenticated session. The provider ARN then targets GitHub's token service inside that account.

The condition reuses an existing provider. Otherwise, AWS creates one with sts.amazonaws.com as its audience.

  • Confirm the output reports reuse or returns details for the newly created provider.

OIDC provider command failed?

  • Confirm that your authenticated identity can read IAM OIDC providers.
  • Confirm that your authenticated identity can create the provider when it is absent.
  • Still stuck? Help me troubleshoot the GitHub OIDC provider setup.
  • Run the role creation commands from the local orderflow-catalog folder containing the infra directory:
aws iam create-role \
  --role-name orderflow-catalog-ci \
  --assume-role-policy-document file://infra/trust-policy.json

aws iam put-role-policy \
  --role-name orderflow-catalog-ci \
  --policy-name EcrPushOrderflowCatalog \
  --policy-document file://infra/ecr-push-policy.json

What do these commands create?

The first command creates orderflow-catalog-ci with the repository-specific OIDC trust policy. The second attaches the inline EcrPushOrderflowCatalog policy.

  • Copy the role ARN from the role creation output into your IAM role ARN.
  • Confirm the inline policy command finishes without an error.

Role or policy creation failed?

  • Confirm that the terminal is inside the orderflow-catalog folder.
  • Confirm that both JSON files are saved inside infra.
  • Still stuck? Help me create the OIDC role and inline ECR policy.

The role ARN is an identifier instead of a credential. A GitHub repository variable makes it available to the workflow without granting any access by itself.

  • Return to the orderflow-catalog repository on GitHub.
  • Select Settings.
  • Select Secrets and variables.
  • Select Actions.
  • Select Variables.
  • Click New repository variable.
  • Enter AWS_ROLE_ARN in the Name field.
  • Paste your IAM role ARN into the Value field.
  • Click Add variable.
  • Confirm AWS_ROLE_ARN appears in the repository variables list.
Publish the first latest image

The repository variable tells the workflow which role to request through OIDC. The Docker image still exists only on the hosted runner until the workflow logs into ECR and pushes it.

  • Open .github/workflows/catalog-ci.yml in the code editor from earlier.
  • Find this permissions block:
permissions:
  contents: read
  • Replace it with this permissions and environment block:
permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: orderflow-catalog
  • Find the Run Catalog image step at the bottom of the workflow.
  • Add these authentication steps below it:
      - name: Configure short-lived AWS credentials
        uses: aws-actions/configure-aws-credentials@v6.2.4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2.1.7

How does the workflow authenticate?

  • The id-token: write permission lets the job request an OIDC token.
  • The AWS credentials action exchanges that token for short-lived role credentials.
  • The ECR login action exposes the registry address through its registry output.
  • Save .github/workflows/catalog-ci.yml.
  • Commit the workflow update with your existing source control workflow.
  • Push the commit to main by running:
git push origin main

The workflow should pass its tests plus container run. The AWS credential and ECR login steps should also finish successfully.

AWS authentication step failed?

  • Confirm that AWS_ROLE_ARN contains the complete ARN for orderflow-catalog-ci.
  • Compare the role trust subject with the exact value printed by the identity workflow.
  • Still stuck? Help me diagnose the GitHub Actions OIDC authentication failure.

Authentication now works. The next workflow step tags the tested local image as latest inside your private registry.

  • Return to .github/workflows/catalog-ci.yml.
  • Add this publishing step below the ECR login step:
      - name: Push latest image
        shell: bash
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          docker tag "catalog:${GITHUB_SHA}" "${REGISTRY}/${ECR_REPOSITORY}:latest"
          docker push "${REGISTRY}/${ECR_REPOSITORY}:latest"

How does publication work?

The registry output supplies your account-specific ECR hostname. The tag command gives the tested local image its private registry address.

The push command uploads the image under latest. This becomes the first value protected by tag immutability.

  • Save .github/workflows/catalog-ci.yml.
  • Commit the publishing step with your existing source control workflow.
  • Push the commit to main by running:
git push origin main

The workflow should finish successfully. Its final step should upload the first latest image.

  • Return to the Amazon ECR console.
  • Select the orderflow-catalog repository.
  • Confirm the repository contains an image tagged latest.

Great work. Your tested Catalog image is now stored in an immutable registry.

First image did not publish?

  • Confirm that the ECR login step completed before the push step started.
  • Confirm that the role policy targets the orderflow-catalog repository in us-east-1.
  • Still stuck? Help me troubleshoot the first ECR image push.

✔️ Awesome, I've got everything

The workflow is saved. The first latest image is visible in ECR.

ⓧ I'd like to double check the full code

Compare your workflow with this complete version for the first mutable-tag publication.

name: Catalog image pipeline

on:
  push:
    branches:
      - main

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: orderflow-catalog

jobs:
  publish:
    runs-on: ubuntu-24.04
    steps:
      - name: Check out Catalog source
        uses: actions/checkout@v7.0.1
      - name: Run Catalog tests
        shell: bash
        run: sh tests/catalog_test.sh
      - name: Build Catalog image
        shell: bash
        run: docker build --tag "catalog:${GITHUB_SHA}" .
      - name: Run Catalog image
        shell: bash
        run: docker run --rm "catalog:${GITHUB_SHA}" --once
      - name: Configure short-lived AWS credentials
        uses: aws-actions/configure-aws-credentials@v6.2.4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}
      - name: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2.1.7
      - name: Push latest image
        shell: bash
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          docker tag "catalog:${GITHUB_SHA}" "${REGISTRY}/${ECR_REPOSITORY}:latest"
          docker push "${REGISTRY}/${ECR_REPOSITORY}:latest"
Change the Catalog and rerun

The first image now owns the latest tag. A visible Catalog change ensures the next container contains different content.

  • Open catalog.sh in your code editor.
  • Find this line:
  printf '%s\n' '{"service":"catalog","status":"ok","items":[{"sku":"NW-101","name":"Keyboard"},{"sku":"NW-102","name":"Mouse"}]}'
  • Replace Mouse with Wireless Mouse so the line looks like this:
  printf '%s\n' '{"service":"catalog","status":"ok","items":[{"sku":"NW-101","name":"Keyboard"},{"sku":"NW-102","name":"Wireless Mouse"}]}'
  • Save catalog.sh.
  • Commit the Catalog change with your existing source control workflow.

✔️ The Catalog change is saved

The second item is now named Wireless Mouse.

ⓧ I'd like to double check the full code

Compare catalog.sh with this complete version.

#!/bin/sh
set -eu

print_catalog() {
  printf '%s\n' '{"service":"catalog","status":"ok","items":[{"sku":"NW-101","name":"Keyboard"},{"sku":"NW-102","name":"Wireless Mouse"}]}'
}

print_catalog

if [ "${1:-}" = "--once" ]; then
  exit 0
fi

trap 'exit 0' TERM INT
while :; do
  sleep 3600 &
  wait "$!"
done

Before you push, what do you expect the immutable repository to do when changed image content uses the existing latest tag?

  • Push the changed Catalog commit to main by running:
git push origin main

The tests pass. The image build now contains Wireless Mouse.

The final push reaches ECR and fails with ImageTagAlreadyExistsException. The immutable latest tag still points to the first image.

This failure is the proof

ECR rejected an attempt to assign changed content to an existing immutable tag. A deployment using latest cannot identify which source commit produced that tag.

The failed run exposes the artifact identity problem before the pipeline becomes responsible for deployment state.

Did the run fail before ECR?

  • Confirm that the test and image build steps passed before diagnosing the tag protection.
  • Confirm that the first latest image still appears in the orderflow-catalog repository.
  • Still stuck? Help me reach the intended immutable-tag failure.

You have seen why a moving tag cannot represent an exact immutable build. Next, you will bind each image to its source commit and add a vulnerability gate before publication.

Scan and Publish Commit-SHA Images

Your immutable Amazon ECR repository rejected the second latest push because that tag already belonged to an earlier image. A secure pipeline gives every build a unique identity before publication.

In this step, you will bind each GitHub Actions run to its commit SHA. You will add a Trivy gate before AWS authentication. An existence check will make retries safe against immutable storage.

In this step, get ready to:
  • Bind every Catalog image to its source commit SHA.
  • Block fixable critical vulnerabilities before AWS authentication.
  • Reuse an existing immutable image during pipeline retries.
Bind each run to its commit SHA

A commit SHA identifies one exact source revision. Workflow concurrency also prevents an older run from publishing after a newer run has started.

  • In .github/workflows/catalog-ci.yml, replace the opening section from name through env with this code:
name: Catalog image pipeline

on:
  push:
    branches:
      - main

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: orderflow-catalog
  IMAGE_TAG: ${{ github.sha }}

How does this bind the run?

  • The concurrency group separates runs by workflow and Git reference.
  • The cancel-in-progress setting stops an older run when a newer run joins the same group.
  • The IMAGE_TAG value captures the commit SHA that triggered the workflow.
  • Save .github/workflows/catalog-ci.yml.
  • Confirm the opening section contains ${{ github.workflow }}-${{ github.ref }}.

The workflow now gives each main-branch run its own artifact identity. A newer run also takes priority over an older run.

Workflow structure looks wrong?

  • Check that concurrency starts at the same indentation level as permissions.
  • Check that IMAGE_TAG remains inside the top-level env block.
  • Still stuck? Help me check the top-level structure of my Catalog GitHub Actions workflow.

The workflow must use that identity consistently during the local build. The same tag will later become the immutable ECR tag.

  • In the same workflow, find the - name: Build Catalog image step.
  • Replace the build block plus the following run block with this code:
      - name: Build Catalog image
        shell: bash
        run: docker build --tag "catalog:${IMAGE_TAG}" .

      - name: Run Catalog image
        shell: bash
        run: docker run --rm "catalog:${IMAGE_TAG}" --once

How does the SHA reach the image?

The Docker build reads IMAGE_TAG from the workflow environment. The run step uses the same local image tag to prove that exact build starts successfully.

  • Save .github/workflows/catalog-ci.yml.
  • Confirm the Catalog source still passes the workflow test by running this command:
sh tests/catalog_test.sh

What does this check prove?

This runs the same Catalog test used by the workflow. It confirms the source still returns the expected service name and healthy status before you change publication logic.

You should see catalog tests passed in your terminal.

Catalog test failing?

  • Check that catalog.sh still contains the Wireless Mouse item from the previous step.
  • Check that you are running the command inside the orderflow-catalog repository.
  • Still stuck? Help me troubleshoot the Catalog shell test before I update the image pipeline.
Gate publication and extend read access

A retry-safe publish needs permission to ask whether an image already exists. The repository-scoped IAM policy gains that read action without widening access to another ECR repository.

  • In infra/ecr-push-policy.json, find the existing ecr:BatchGetImage action.
  • Add a comma immediately after the closing quote.
  • Add ecr:DescribeImages as the next action in the same repository-scoped array.
  • Save infra/ecr-push-policy.json.
  • Return to your authenticated AWS terminal from earlier.
  • Update the inline role policy by running this command:
aws iam put-role-policy \
  --role-name orderflow-catalog-ci \
  --policy-name EcrPushOrderflowCatalog \
  --policy-document file://infra/ecr-push-policy.json

What does this command change?

The command replaces the existing inline policy document on orderflow-catalog-ci. The new permission lets the workflow read image metadata from orderflow-catalog before deciding whether to push.

AWS returns you to the terminal prompt without an error when the policy update succeeds.

Policy update rejected?

  • Check that infra/ecr-push-policy.json contains valid JSON with a comma between the two final repository actions.
  • Check that your authenticated AWS identity can update inline policies on orderflow-catalog-ci.
  • Still stuck? Help me troubleshoot the EcrPushOrderflowCatalog inline policy update.

The image must pass its vulnerability gate before the workflow requests AWS credentials. This keeps a fixable critical vulnerability away from the registry boundary.

  • In .github/workflows/catalog-ci.yml, find the end of the Run Catalog image step.
  • Insert this scan step immediately below it:
      - name: Scan Catalog image
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: catalog:${{ github.sha }}
          format: table
          exit-code: '1'
          ignore-unfixed: true
          scanners: vuln
          severity: CRITICAL
          version: v0.74.0

Why scan before AWS authentication?

The action scans the locally built image before publication. This gives CI a decision point before it receives short-lived AWS credentials.

  • The exit-code: '1' setting fails the job when a selected finding is present.
  • The ignore-unfixed: true setting excludes findings without an available fix.
  • The severity: CRITICAL setting limits the gate to critical vulnerabilities.
  • The version: v0.74.0 setting pins the scanner version used by aquasecurity/trivy-action@v0.36.0.
  • Save .github/workflows/catalog-ci.yml.
  • Confirm the Scan Catalog image step appears above Configure short-lived AWS credentials.

The workflow now reaches AWS authentication only after tests, image execution, and the Trivy gate succeed.

Scan step in the wrong place?

  • Check that - name: Scan Catalog image has the same indentation as the other workflow steps.
  • Check that every Trivy input is nested beneath with.
  • Still stuck? Help me place the Trivy image gate before AWS authentication.
Reuse immutable images safely

An immutable commit-SHA tag is safe to publish once. A retry first checks ECR for that exact tag so the same workflow can complete without attempting an overwrite.

  • In .github/workflows/catalog-ci.yml, find the existing image push step that uses latest.
  • Replace that entire step with this code:
      - name: Publish immutable image when absent
        shell: bash
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          image_uri="${REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
          if aws ecr describe-images \
            --repository-name "$ECR_REPOSITORY" \
            --image-ids imageTag="$IMAGE_TAG" \
            --region "$AWS_REGION" >/dev/null 2>&1; then
            printf 'Image %s already exists; reusing it\n' "$image_uri"
          else
            docker tag "catalog:${IMAGE_TAG}" "$image_uri"
            docker push "$image_uri"
          fi

How does the retry stay safe?

  • The image_uri value combines the ECR registry with the repository name and commit SHA.
  • The existence check looks for the exact IMAGE_TAG before any push begins.
  • An existing tag is reused because it already represents this source commit.
  • An absent tag is created by tagging the tested local image and pushing it once.
  • Save .github/workflows/catalog-ci.yml.
  • Confirm the publish block calls aws ecr describe-images before docker push.

Your workflow now checks immutable storage before writing to it. Compare the complete file before triggering the run.

✔️ Awesome, I've got everything!

Great. Your workflow has commit-SHA tags, a pre-authentication Trivy gate, and a retry-safe ECR publish step.

ⓧ I'd like to double check the full code

  • Compare .github/workflows/catalog-ci.yml with this cumulative version:
name: Catalog image pipeline

on:
  push:
    branches:
      - main

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: orderflow-catalog
  IMAGE_TAG: ${{ github.sha }}

jobs:
  publish:
    runs-on: ubuntu-24.04
    steps:
      - name: Check out Catalog source
        uses: actions/checkout@v7.0.1

      - name: Run Catalog tests
        shell: bash
        run: sh tests/catalog_test.sh

      - name: Build Catalog image
        shell: bash
        run: docker build --tag "catalog:${IMAGE_TAG}" .

      - name: Run Catalog image
        shell: bash
        run: docker run --rm "catalog:${IMAGE_TAG}" --once

      - name: Scan Catalog image
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: catalog:${{ github.sha }}
          format: table
          exit-code: '1'
          ignore-unfixed: true
          scanners: vuln
          severity: CRITICAL
          version: v0.74.0

      - name: Configure short-lived AWS credentials
        uses: aws-actions/configure-aws-credentials@v6.2.4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2.1.7

      - name: Publish immutable image when absent
        shell: bash
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          image_uri="${REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
          if aws ecr describe-images \
            --repository-name "$ECR_REPOSITORY" \
            --image-ids imageTag="$IMAGE_TAG" \
            --region "$AWS_REGION" >/dev/null 2>&1; then
            printf 'Image %s already exists; reusing it\n' "$image_uri"
          else
            docker tag "catalog:${IMAGE_TAG}" "$image_uri"
            docker push "$image_uri"
          fi

Before you push, do you expect this commit SHA to be new to ECR or already present from an earlier attempt?

  • Push the corrected workflow to main by running this command:
git push origin main

What does this push trigger?

The push starts the Catalog image pipeline for the new commit SHA. The workflow tests the service before building and scanning the matching image.

AWS authentication begins after the scan passes. The final step either publishes the absent SHA tag or safely reuses the same immutable artifact.

In the Actions log, you should see a passing Trivy table before the AWS credential step. You should then see image push output or a message explaining that the same tag is being reused.

You have turned the failed mutable publish into a scan-gated immutable artifact flow. Each successful image now points back to one exact source commit.

Pipeline not completing?

  • Check that the Trivy step appears before AWS authentication in .github/workflows/catalog-ci.yml.
  • Check that the applied role policy includes ecr:DescribeImages for the orderflow-catalog repository.
  • Check that every build and publish reference uses IMAGE_TAG instead of latest.
  • Still stuck? Help me troubleshoot my Trivy-gated commit-SHA ECR pipeline.

Your tested Catalog image now reaches ECR under an immutable commit SHA. Next, you will hand that exact artifact to the deployment repository through Git without giving CI access to a cluster.

Hand Deployment Off Through Git

Your GitHub Actions pipeline now publishes a tested commit-SHA image to Amazon ECR. The deployment repository still needs to record which exact image should run.

A GitOps handoff records desired state in Git. The workflow can update the separate repository without receiving Kubernetes or Argo CD credentials.

In this step, get ready to:
  • Create a write-enabled deploy key for the GitOps repository.
  • Add a script that validates the image tag before updating the manifest.
  • Extend the workflow to commit desired state without cluster access.
Generate a repository-scoped deploy key

A deploy key gives one automation identity access to one repository. Write access lets the source pipeline update deployment configuration without receiving broader GitHub account permissions.

OpenSSH asks where to save the key pair. It also asks for a passphrase. You will use a dedicated path with an empty passphrase so the non-interactive checkout can use the private key.

  • Generate the Ed25519 deploy key by running this command:
ssh-keygen -t ed25519 -C "orderflow-catalog-ci"

What does this command create?

  • The ed25519 key type creates a private key for the workflow and a matching public key for GitHub.
  • The -C value labels the key as orderflow-catalog-ci so its purpose stays clear in the repository settings.
  • The private key grants access. The public key can be installed on the GitOps repository.
  • Enter ~/.ssh/orderflow-gitops-deploy when OpenSSH asks where to save the key.
  • Press Enter when OpenSSH asks for a passphrase.
  • Press Enter again when OpenSSH asks you to confirm the empty passphrase.

You should see confirmation that the private key and public key were saved at the dedicated paths. Your repository-scoped credential is now ready to connect the two repositories.

  • Use a Linux text editor to open ~/.ssh/orderflow-gitops-deploy.pub.
  • Copy the full contents of the public key file.

Why is the public key safe to share?

The public key lets GitHub recognize the matching private key during authentication. It cannot authenticate by itself.

The deploy key remains scoped to orderflow-gitops. That boundary limits the workflow to the one repository it needs to update.

  • Return to the orderflow-gitops repository on GitHub.
  • Select Settings.
  • Select Deploy Keys.
  • Select Add deploy key.
  • Enter orderflow-catalog-ci in the Title field.
  • Paste the public key into the Key field.
  • Select Allow write access.
  • Select Add key.

The deploy key list should now show orderflow-catalog-ci with write access. GitHub can now accept authenticated pushes from the matching private key.

The private key is a credential. You will place it only in the encrypted secrets area of the source repository. Keep it out of commits and screenshots.

  • Use a Linux text editor to open ~/.ssh/orderflow-gitops-deploy.
  • Copy the complete private key contents.
  • Return to the orderflow-catalog repository on GitHub.
  • Select Settings.
  • Select Secrets and variables.
  • Select Actions.

The Actions settings separate encrypted secrets from visible configuration variables. The private key belongs in the secrets area.

  • Select Secrets.
  • Select New repository secret.
  • Enter GITOPS_SSH_KEY in the Name field.
  • Paste the private key into the Secret field.
  • Select Add secret.

GitHub should list GITOPS_SSH_KEY without revealing its value. That masked entry confirms the credential is stored safely.

  • Select Variables.
  • Select New repository variable.
  • Enter GITOPS_REPOSITORY in the Name field.
  • Enter OWNER/orderflow-gitops in the Value field.
  • Select Add variable.
Guard the manifest update

The workflow needs a narrow way to change the deployment manifest. The update script accepts an image only when its tag matches the triggering source commit SHA.

  • Return to your local orderflow-catalog checkout.
  • Create a scripts folder inside orderflow-catalog using your editor's file sidebar.
  • Create scripts/update-image.sh inside the new folder.
  • Add the script inputs and tag guard by pasting this first chunk:
#!/usr/bin/env bash
set -euo pipefail

image_uri="${1:?usage: update-image.sh IMAGE_URI EXPECTED_TAG MANIFEST}"
expected_tag="${2:?usage: update-image.sh IMAGE_URI EXPECTED_TAG MANIFEST}"
manifest="${3:?usage: update-image.sh IMAGE_URI EXPECTED_TAG MANIFEST}"
actual_tag="${image_uri##*:}"

if [[ "$actual_tag" != "$expected_tag" ]]; then
  printf 'refusing image tag %s; expected commit tag %s\n' \
    "$actual_tag" "$expected_tag" >&2
  exit 1
fi

updated=''
image_lines=0

How does the tag guard work?

  • The first three variables hold the image URI, expected commit tag, and manifest path supplied by the workflow.
  • The actual_tag value extracts everything after the final colon in the image URI.
  • The guard exits when the image tag differs from expected_tag. This prevents the deployment repository from receiving an unrelated image reference.
  • Save scripts/update-image.sh.
  • Confirm the file shows three required inputs followed by the image-tag comparison.

Does the guard look incomplete?

  • Check that each positional argument uses the matching usage message from the code block.
  • Check that the comparison uses actual_tag on the left and expected_tag on the right.
  • Still stuck? Help me check the input and tag guard in update-image.sh.

A safe update must change exactly one image: field. Multiple matches could rewrite the wrong container. Zero matches would leave desired state unchanged.

  • Append the manifest rewrite logic below image_lines=0 by pasting this chunk:
while IFS= read -r line || [[ -n "$line" ]]; do
  if [[ "$line" =~ ^([[:space:]]*)image:[[:space:]] ]]; then
    line="${BASH_REMATCH[1]}image: ${image_uri}"
    image_lines=$((image_lines + 1))
  fi
  updated+="${line}"$'\n'
done < "$manifest"

if [[ "$image_lines" -ne 1 ]]; then
  printf 'expected exactly one image field in %s, found %s\n' \
    "$manifest" "$image_lines" >&2
  exit 1
fi

printf '%s' "$updated" > "$manifest"
printf 'updated %s to %s\n' "$manifest" "$image_uri"

How does the manifest update stay narrow?

  • The loop preserves each manifest line while counting fields that begin with image:.
  • The matching field keeps its original indentation. Its value becomes the validated ECR image URI.
  • The second guard requires exactly one match before the script writes the updated content back to the manifest.
  • Save scripts/update-image.sh.
  • Confirm the file ends with one manifest write and one update message.

Seeing mismatched Bash syntax?

  • Check that the while loop ends with done.
  • Check that each conditional ends with fi.
  • Still stuck? Help me find the Bash syntax mismatch in update-image.sh.

✔️ Awesome, I've got everything!

Your update script now binds the deployment image to the source commit SHA. It also refuses manifests with an unexpected number of image fields.

ⓧ I'd like to double check the full code

  • Compare scripts/update-image.sh with this complete file:
#!/usr/bin/env bash
set -euo pipefail

image_uri="${1:?usage: update-image.sh IMAGE_URI EXPECTED_TAG MANIFEST}"
expected_tag="${2:?usage: update-image.sh IMAGE_URI EXPECTED_TAG MANIFEST}"
manifest="${3:?usage: update-image.sh IMAGE_URI EXPECTED_TAG MANIFEST}"
actual_tag="${image_uri##*:}"

if [[ "$actual_tag" != "$expected_tag" ]]; then
  printf 'refusing image tag %s; expected commit tag %s\n' \
    "$actual_tag" "$expected_tag" >&2
  exit 1
fi

updated=''
image_lines=0
while IFS= read -r line || [[ -n "$line" ]]; do
  if [[ "$line" =~ ^([[:space:]]*)image:[[:space:]] ]]; then
    line="${BASH_REMATCH[1]}image: ${image_uri}"
    image_lines=$((image_lines + 1))
  fi
  updated+="${line}"$'\n'
done < "$manifest"

if [[ "$image_lines" -ne 1 ]]; then
  printf 'expected exactly one image field in %s, found %s\n' \
    "$manifest" "$image_lines" >&2
  exit 1
fi

printf '%s' "$updated" > "$manifest"
printf 'updated %s to %s\n' "$manifest" "$image_uri"
Extend the workflow handoff

The GitOps checkout belongs after the image publication step. That order ensures the deployment repository receives only an image that ECR has already published or safely reused.

  • Return to .github/workflows/catalog-ci.yml in your local orderflow-catalog checkout.
  • Find the Publish immutable image when absent step.
  • Add the GitOps checkout immediately below that step by pasting this block:
      - name: Check out GitOps repository
        uses: actions/checkout@v7.0.1
        with:
          repository: ${{ vars.GITOPS_REPOSITORY }}
          ref: main
          ssh-key: ${{ secrets.GITOPS_SSH_KEY }}
          path: gitops

What does this checkout do?

  • The checkout targets the repository stored in GITOPS_REPOSITORY.
  • The GITOPS_SSH_KEY secret authenticates Git operations with the repository-scoped deploy key.
  • The path input places the deployment repository in a gitops directory beside the source checkout.
  • Save .github/workflows/catalog-ci.yml.
  • Confirm the new checkout appears after the immutable image publication step.

Is the GitOps checkout in the wrong place?

  • Check that the new step aligns with the other entries under steps:.
  • Move the checkout below Publish immutable image when absent if it appears earlier in the job.
  • Still stuck? Help me place the GitOps checkout correctly in catalog-ci.yml.

The final workflow step updates the manifest in the checked-out repository. A staged-diff check turns retries into a no-op when the manifest already points to the desired image.

  • Find the new Check out GitOps repository step.
  • Add the desired-state update immediately below it by pasting this block:
      - name: Update and push deployment configuration
        shell: bash
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          image_uri="${REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
          bash scripts/update-image.sh \
            "$image_uri" \
            "$GITHUB_SHA" \
            gitops/apps/catalog/deployment.yaml
          cd gitops
          git config user.name "orderflow-catalog-ci"
          git config user.email "orderflow-catalog-ci@example.invalid"
          git add apps/catalog/deployment.yaml
          if git diff --cached --quiet; then
            printf '%s\n' 'GitOps configuration already points to this image'
          else
            git commit -m "Deploy catalog ${IMAGE_TAG}"
            git push origin HEAD:main
          fi

How does the Git handoff work?

  • The workflow builds image_uri from the authenticated ECR registry, repository name, and source commit tag.
  • The update script compares that tag with GITHUB_SHA before changing gitops/apps/catalog/deployment.yaml.
  • The workflow stages only apps/catalog/deployment.yaml inside the GitOps checkout.
  • The git diff --cached --quiet check skips the commit when the staged manifest already matches HEAD.
  • A real change becomes a commit on the GitOps repository's main branch.
  • Save .github/workflows/catalog-ci.yml.
  • Confirm the update step follows the GitOps checkout as the final workflow step.

✔️ Awesome, I've got everything!

Your workflow now publishes an immutable image before committing the matching desired state to the separate GitOps repository.

ⓧ I'd like to double check the full code

  • Compare .github/workflows/catalog-ci.yml with this complete workflow:
name: Catalog image pipeline

on:
  push:
    branches:
      - main

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: orderflow-catalog
  IMAGE_TAG: ${{ github.sha }}

jobs:
  publish:
    runs-on: ubuntu-24.04
    steps:
      - name: Check out Catalog source
        uses: actions/checkout@v7.0.1

      - name: Run Catalog tests
        shell: bash
        run: sh tests/catalog_test.sh

      - name: Build Catalog image
        shell: bash
        run: docker build --tag "catalog:${IMAGE_TAG}" .

      - name: Run Catalog image
        shell: bash
        run: docker run --rm "catalog:${IMAGE_TAG}" --once

      - name: Scan Catalog image
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: catalog:${{ github.sha }}
          format: table
          exit-code: '1'
          ignore-unfixed: true
          scanners: vuln
          severity: CRITICAL
          version: v0.74.0

      - name: Configure short-lived AWS credentials
        uses: aws-actions/configure-aws-credentials@v6.2.4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2.1.7

      - name: Publish immutable image when absent
        shell: bash
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          image_uri="${REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
          if aws ecr describe-images \
            --repository-name "$ECR_REPOSITORY" \
            --image-ids imageTag="$IMAGE_TAG" \
            --region "$AWS_REGION" >/dev/null 2>&1; then
            printf 'Image %s already exists; reusing it\n' "$image_uri"
          else
            docker tag "catalog:${IMAGE_TAG}" "$image_uri"
            docker push "$image_uri"
          fi

      - name: Check out GitOps repository
        uses: actions/checkout@v7.0.1
        with:
          repository: ${{ vars.GITOPS_REPOSITORY }}
          ref: main
          ssh-key: ${{ secrets.GITOPS_SSH_KEY }}
          path: gitops

      - name: Update and push deployment configuration
        shell: bash
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          image_uri="${REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
          bash scripts/update-image.sh \
            "$image_uri" \
            "$GITHUB_SHA" \
            gitops/apps/catalog/deployment.yaml
          cd gitops
          git config user.name "orderflow-catalog-ci"
          git config user.email "orderflow-catalog-ci@example.invalid"
          git add apps/catalog/deployment.yaml
          if git diff --cached --quiet; then
            printf '%s\n' 'GitOps configuration already points to this image'
          else
            git commit -m "Deploy catalog ${IMAGE_TAG}"
            git push origin HEAD:main
          fi
  • Commit scripts/update-image.sh to main using your existing Git workflow.
  • Commit .github/workflows/catalog-ci.yml to main using your existing Git workflow.

Before you push, which system do you expect CI to change: the GitOps repository or the cluster?

  • Push the committed source changes by running this command:
git push origin main

What does this push prove?

The push starts the Catalog image pipeline for the new source commit. A successful run publishes or reuses the matching ECR image before updating the GitOps repository.

The final effect is a Git commit. CI never sends a deployment command to a cluster.

This run can take a few minutes while the workflow tests, builds, scans, publishes, and checks out the second repository. The security gate and registry work account for most of that wait.

  • Select Actions in the orderflow-catalog repository.
  • Open the newest Catalog image pipeline run.
  • Record the triggering commit SHA here: source commit SHA.
  • Confirm Scan Catalog image finishes before AWS authentication.
  • Confirm Check out GitOps repository succeeds after image publication.
  • Confirm Update and push deployment configuration succeeds as the final step.
  • Return to apps/catalog/deployment.yaml in the orderflow-gitops repository.
  • Confirm the image reference ends with source commit SHA.

That is the handoff complete. Your source commit now identifies an immutable ECR image and a matching GitOps commit.

  • Review .github/workflows/catalog-ci.yml to confirm it contains no cluster endpoint.
  • Use your editor's search to confirm the workflow contains no kubeconfig.
  • Use your editor's search to confirm the workflow contains no kubectl.
  • Use your editor's search to confirm the workflow contains no Kubernetes API credential.
  • Use your editor's search to confirm the workflow contains no Argo CD credential.

Where does deployment authority live?

CI can publish one image and update one repository. A separate GitOps reconciler can observe that commit and apply it within the cluster's own security boundary.

The pipeline holds an AWS image-push role and one repository-scoped Git key. Deployment authority stays outside CI.

Secret mission

Scan the Configuration Too

Your image scan checks the software packaged inside the Catalog image. Extend the pipeline with a Trivy configuration gate that blocks high and critical Dockerfile findings before the image is built or published.

Clean Up Your Resources

Private repository storage costs $0.10 per GB each month beyond eligible free-tier usage. Pausing the workflow leaves its images stored, so choose the delete option if you want those storage charges to stop.

Nice Work!

Nice Work!

Mission accomplished! Your GitHub Actions pipeline now turns Catalog source commits into scanned images with immutable tags. Each successful run publishes to Amazon ECR before recording deployment intent in a separate GitOps repository.

You've learned how to:

  • Create a tested Catalog image whose workflow log proves the container prints Catalog JSON. Diagnose the designed immutable-tag failure when a changed image tries to reuse latest.
  • Place a vulnerability gate powered by Trivy before AWS authentication. Publish passing commit-SHA images to Amazon ECR with short-lived OIDC credentials. Make retries safe by reusing an existing immutable tag.
  • Update a separate GitOps repository through a repository-scoped deploy key. Skip empty commits when desired state already matches the published image. Serialize main-branch runs to prevent stale updates. Keep cluster deployment authority outside CI. The workflow contains no Kubernetes credentials or Argo CD tokens.
  • Complete the optional Secret Mission by adding a second Trivy configuration gate. It blocks high and critical Dockerfile findings before the image build.

Ready to quiz yourself?