Lint, Package, Deploy

A Workflow That Lints, Packages and Deploys to LocalStack

infra/catalog-api.yaml declares the CDK 24 app's resources by hand, with a role allowing only dynamodb:Scan and Code: ../api/, a path that aws cloudformation package zips, uploads and replaces. cfn-lint 2,641 flags that path (W3002) and exits with 4, failing the job, so .cfnlintrc.yaml ignores W3002, with a comment saying why:

.github/workflows/deploy.yml: lint, package, deploy and test against LocalStackYAML
name: Deploy to LocalStack
on:
  push:
    branches: [main]
    paths: ["infra/**", "api/**", ".github/workflows/deploy.yml"]
  workflow_dispatch:
permissions:
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-24.04
    services:
      localstack:
        image: localstack/localstack:4.13.1
        ports: ["4566:4566"]
        volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
    env:
      AWS_ENDPOINT_URL: http://localhost:4566
      AWS_DEFAULT_REGION: us-east-1
      AWS_ACCESS_KEY_ID: test
      AWS_SECRET_ACCESS_KEY: test
    steps:
      - uses: actions/checkout@v7
      - name: Lint the templates
        run: |
          pipx install cfn-lint==1.57.0
          cfn-lint && echo "cfn-lint: no findings"
      - name: Package the Lambda code
        run: |
          aws s3 mb s3://booknest-artifacts
          aws cloudformation package --template-file infra/catalog-api.yaml \
            --s3-bucket booknest-artifacts --output-template-file packaged.yaml
      - name: Deploy the stack
        run: |
          aws cloudformation deploy --stack-name booknest-api \
            --template-file packaged.yaml --capabilities CAPABILITY_IAM
      - name: Smoke-test the function
        run: |
          aws dynamodb put-item --table-name booknest-books \
            --item '{"id": {"N": "1"}, "title": {"S": "The Quiet Harbor"}}'
          aws lambda invoke --function-name booknest-count-books out.json
          cat out.json
      - name: Remove the Lambda containers LocalStack started
        if: always()
        run: |
          docker ps -q --filter network=${{ job.services.localstack.network }} \
            --filter name=-lambda- | xargs -r docker rm -f

The runner waits for the image's own health check before the first step, and the published port puts LocalStack 63,725 at localhost:4566; the test keys are dummies. LocalStack starts the Lambda 24 container on the job's network, which made the first run warn "Docker 514 network rm failed"; the last step, run even after failures, fixed that. The third run's log:

The key lines of run 36186186288Shell
gh run view 36186186288 --log | cut -f2,3 | sed -E 's/\t\S+Z /: /' \
  | grep -E 'Image:|is healthy|installed package|findings$|Successfully c|"books'
Output
Set up job: Image: ubuntu-24.04
Initialize containers: localstack service is healthy.
Lint the templates:   installed package cfn-lint 1.57.0, installed using Python 3.12.3
Lint the templates: cfn-lint: no findings
Deploy the stack: Successfully created/updated stack - booknest-api
Smoke-test the function: {"statusCode":200,"body":"{\"books\":1}"}
The job page of run 36186186288: every step passed in 1 minute 36 seconds
The job page of run 36186186288: every step passed in 1 minute 36 seconds

Each run starts empty, so it proves the templates deploy from nothing, not that they update cleanly.