DevOps & CI/CD Bug Report

CI pipeline red on "No changes to deploy": sam deploy, empty changesets and environment config

Our GitHub Actions pipeline for a serverless API failed every time a merge did not change the stack, such as documentation-only commits: Error: No changes to deploy. How CloudFormation empty changesets work, the flag that makes sam deploy exit cleanly, and the samconfig and OIDC setup we use per environment.

Tomás Herrera Tomás Herrera @tomasherrera · October 07, 2026 · 7 min read · 89 Views
Banner: CI pipeline red on No changes to deploy, sam deploy, empty changesets and environment config

We deploy a serverless API with AWS SAM from GitHub Actions: build, then sam deploy to staging on every merge to main, and to production on tags. One day a pull request that only changed the README was merged, and the pipeline failed:

Initiating deployment
=====================

Waiting for changeset to be created..

Error: No changes to deploy. Stack orders-api-staging is up to date
Error: Process completed with exit code 1.

From then on, any merge that did not change the stack template or function code turned the pipeline red: documentation updates, test-only changes, workflow edits. The team started ignoring red builds on main, which is worse than the original problem.

What sam deploy does

sam deploy uploads artifacts, then creates a CloudFormation changeset describing the difference between the deployed stack and the new template. If the changeset is empty, CloudFormation marks it FAILED with the reason "The submitted information didn't contain changes", and SAM reports "No changes to deploy" and exits with status 1.

aws cloudformation describe-change-set --stack-name orders-api-staging --change-set-name samcli-deploy1759...
"Status": "FAILED",
"StatusReason": "The submitted information didn't contain changes. Submit different information to create a change set."

Why "nothing changed" when we rebuilt

SAM builds are deterministic for our Python functions: the same source and dependencies produce the same zip, with the same hash, so the S3 artifact key and therefore the template stayed identical. That is the desired behaviour. It just collides with a deploy step that treats "no difference" as an error.

What we ruled out

  • Stale credentials or a wrong account. The changeset was created in the right stack; it was empty.
  • Changes hidden by caching. A real code change produced a non-empty changeset as expected.
  • Drift. CloudFormation changesets compare templates and parameters, not live resources, so drift is irrelevant here.

Root cause

sam deploy, like aws cloudformation deploy, exits with a non-zero status when the changeset is empty, unless told otherwise. Our pipeline did not handle that case, so every merge without infrastructure or code changes failed the deploy job.

Empty changesets are a normal outcome

In a pipeline that deploys on every merge, most merges in a mature service do not change the deployed stack. Treating that as an error is a choice the tools leave to you. Both sam deploy and aws cloudformation deploy have a flag for it:

--no-fail-on-empty-changeset    Return exit code 0 when there are no changes to be made to the stack.

Where configuration should live

The other half of our problem was that pipeline steps had grown long command lines with stack names, regions, capabilities and parameter overrides duplicated per environment. SAM's samconfig.toml supports named environments, which keeps that configuration in the repository, next to the template, and reviewed like any other code. The pipeline then only has to choose the environment name.

Who is affected

Every pipeline that runs sam deploy or aws cloudformation deploy on each merge, including multi-stack pipelines where most stacks are unchanged on a given commit. Teams that use --no-execute-changeset to review changesets see the same behaviour: the empty changeset fails to create. Pipelines that run cdk deploy are not affected in the same way, because the CDK CLI skips unchanged stacks by default.

Environment

Component Version / value
SAM CLI 1.14x
CI GitHub Actions, ubuntu-latest runners
Credentials OIDC to an IAM role per environment
Stacks orders-api-staging, orders-api-prod

samconfig.toml with environments

version = 0.1

[default.global.parameters]
stack_name = "orders-api"

[default.build.parameters]
cached = true
parallel = true

[staging.deploy.parameters]
stack_name = "orders-api-staging"
region = "eu-west-1"
s3_prefix = "orders-api-staging"
resolve_s3 = true
capabilities = "CAPABILITY_IAM"
confirm_changeset = false
fail_on_empty_changeset = false
parameter_overrides = "Stage=staging LogLevel=info"
tags = "service=orders-api environment=staging"

[prod.deploy.parameters]
stack_name = "orders-api-prod"
region = "eu-west-1"
s3_prefix = "orders-api-prod"
resolve_s3 = true
capabilities = "CAPABILITY_IAM"
confirm_changeset = false
fail_on_empty_changeset = false
parameter_overrides = "Stage=prod LogLevel=warn"
tags = "service=orders-api environment=prod"

fail_on_empty_changeset = false is the config file form of --no-fail-on-empty-changeset.

The workflow

name: deploy
on:
  push:
    branches: [main]
    tags: ["v*"]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ startsWith(github.ref, 'refs/tags/') && 'prod' || 'staging' }}
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/setup-sam@v2
        with:
          use-installer: true
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }}
          aws-region: eu-west-1
      - run: sam build
      - run: sam deploy --config-env ${{ startsWith(github.ref, 'refs/tags/') && 'prod' || 'staging' }}

Each GitHub environment has its own DEPLOY_ROLE_ARN variable, and the IAM role trust policy only allows that repository and environment:

"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:example-org/orders-api:environment:prod"
  }
}

Skip the deploy when nothing deployable changed

Exiting cleanly on an empty changeset fixed the red builds, but each run still spent two minutes building and creating a changeset. For pushes to main, we added a path filter so documentation-only changes do not start the deploy job at all:

on:
  push:
    branches: [main]
    paths-ignore: ["**.md", "docs/**", ".github/ISSUE_TEMPLATE/**"]

Tags always deploy.

Alternatives and trade-offs

  • Catching the error in shell (sam deploy || grep "No changes to deploy"). Works, but string matching on error messages breaks when messages change. The flag is the supported way.
  • Deploying with sam sync. Faster for development, not intended for production pipelines.

What about stack outputs?

One consequence of skipping a deploy: steps that read stack outputs after sam deploy, such as the API URL for smoke tests, must not depend on the deploy having happened in this run. Ours now read outputs with aws cloudformation describe-stacks after the deploy step, which works whether the stack changed or not, and the smoke tests run on every merge.

How to test the fix

Push a README-only commit to a test branch with the workflow pointed at a sandbox stack, and confirm the deploy step logs "No changes to deploy" and exits 0:

sam deploy --config-env staging; echo "exit=$?"
# Error: No changes to deploy. Stack orders-api-staging is up to date
# exit=0

Confirming in production

Over the following month, the main pipeline stayed green for 46 merges, 19 of which produced empty changesets. Production deploys from tags were unaffected.

Regression guard

  • Deploy configuration lives in samconfig.toml, reviewed with code; workflow files only select the environment.
  • A required status check on main ensures red builds block merges, which only works because red now means something.

Security implications

Moving to OIDC with per-environment roles removed long-lived AWS access keys from GitHub secrets. The sub condition pins each role to one repository and one GitHub environment, so a workflow in another repository, or a pull request job without the environment, cannot assume the production role. Production deploys additionally require approval through GitHub environment protection rules.

Prevention checklist

  • Treat empty changesets as success in deployment pipelines.
  • Keep per-environment SAM settings in samconfig.toml, not in long command lines.
  • Use OIDC roles scoped to repository and environment instead of access keys.
  • Skip deploy jobs for changes that cannot affect the stack.
  • Keep pipelines green so red builds are noticed.

References

Add a label

#github-actions #oidc #ci-cd #cloudformation #aws-sam
Written by
Tomás Herrera @tomasherrera

I build and operate event-driven systems on AWS: Lambda, Step Functions, SQS, SNS, EventBridge and ECS on Fargate, mostly for e-commerce and logistics workloads where a stuck message means a stuck order. Serverless removes a lot of work, but it also hides limits until the day you hit them: payload sizes, trigger timeouts, scale-in behaviour, resource policies that silently drop events. I write down each one we hit, with the exact error, the configuration that caused it and the change that fixed it, so you can find the limit in a blog post instead of an incident channel.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.