From Chaos to Control: GitHub Rule Sets and Workflows for Safer AWS Deployments

My Journey to a Hardened AWS Deployment Pipeline

Over the past few months, I’ve been building and refining a monorepo hosting different parts of my AWS-based application:
• iac/: Infrastructure as Code using AWS CDK
• serverless/: Lambda fun…


This content originally appeared on DEV Community and was authored by Dom Derrien

My Journey to a Hardened AWS Deployment Pipeline

Over the past few months, I’ve been building and refining a monorepo hosting different parts of my AWS-based application:
iac/: Infrastructure as Code using AWS CDK
serverless/: Lambda functions with Jest unit tests
webapp/: A Vite+Lit single-page application
cdn/: Static assets destined for S3

Initially, our pipeline favored speed over control. Merging a pull request into develop would instantly deploy a new Develop stack—great for feedback and previews, but risky in the long run.

Production deployments were gated behind manual pull requests from develop to main. This informal control worked well, but it relied on our team’s discipline, rather than rule-enforced validation.

As the system grew, I added Dependabot to monitor dependency freshness, but that wasn’t enough. We needed more structure, more checks—and above all, a pipeline that wouldn’t deploy code before it was reviewed.

I decided to implement GitHub Rule Sets and re-architect the GitHub Actions workflows. The goal: validate every meaningful step before merging anything.

These new Rule Sets enforce:
• ✅ Successful builds and tests for any updated project
• 🔍 Security checks: npm audit, ESLint, and static analysis
• 🧠 A cdk diff that compares the PR’s infra against the actual deployed stack, with protection from malicious code
• 🛑 Delayed deployment: no more deploying on PR creation—only after an approved merge

The result? A tighter, safer, and more trustworthy pipeline.

Streamlining Validation: Leveraging Status Checks with Parallel Pipelines

The initial workflow files (one named Develop for PR created in the develop branch, and one named Prod for the prod branch) were building the code (Lambdas and webapp), running the test suites, packaging the assets, and finally deploying them with the corresponding CDK stack definition.

My first update was to split them into two batches:

  • The CI part (building and testing) is triggered when the PR is created against the develop branch.
  • The CD part (building, packaging, and deploying) is triggered when a push happens in the PR branch, as a result of a PR merge.

To ease the review process, I wanted to expose the validation checks for each project. To achieve that goal, I decided to define a GitHub Rule Set that will check the status of each validation step.

Notes:

  • It's recommended to create a different rule set per responsibility–I set one for core verification, and one for the branch protection.
  • To make it as part of the review process, it is suggested to define them with JSON files that are committed to the repository.
  • Different from the GitHub Action definitions or the issue templates, these JSON files should be submitted to GitHub via a REST API reachable with the gh CLI.
  • During development, it's suggested to allow repository administrators or identified individuals or teams to bypass the verifications—actor_id: 5
{
    "name": "Core Branch Protection",
    "target": "branch",
    "enforcement": "active",
    "conditions": {
        "ref_name": {
            "include": ["refs/heads/main", "refs/heads/develop"],
            "exclude": []
        }
    },
    "rules": [
        {
            "type": "pull_request"
        },
        {
            "type": "required_status_checks",
            "parameters": {
                "required_status_checks": [
                    {
                        "context": "CI Checks (IaC)"
                    },
                    {
                        "context": "CI Checks (Serverless)"
                    },
                    {
                        "context": "CI Checks (Webapp)"
                    }
                ],
                "strict_required_status_checks_policy": true
            }
        },
        {
            "type": "deletion"
        },
        {
            "type": "non_fast_forward"
        }
    ],
    "bypass_actors": [
        {
            "actor_type": "RepositoryRole",
            "actor_id": 5
        }
    ]
}

The workflow defining the validation checks relies on Matrix Strategy: it allows processing a list of steps with { name; command } in parallel.

name: CI/CD Pipeline

on:
    pull_request:
        types: [opened, synchronize, reopened]
    workflow_dispatch:

permissions:
    contents: read
    checks: write
    pull-requests: read

jobs:
    setup:
        name: Setup Dependencies
        runs-on: ubuntu-latest

        steps:
            - name: Checkout code
              uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: '20'
                  cache: 'npm'

            - name: Install all dependencies
              run: |
                  npm ci
                  cd iac && npm ci
                  cd ../serverless && npm ci
                  cd ../webapp && npm ci

            - name: Cache workspace with dependencies
              uses: actions/cache/save@v4
              with:
                  path: .
                  key: node-modules-${{ runner.os }}-${{ github.sha }}

    ci-checks:
        name: CI Checks
        runs-on: ubuntu-latest
        needs: [setup]
        strategy:
            matrix:
                task:
                    - { task: 'IaC', command: 'cd iac && npm run build && npm test' }
                    - { task: 'Serverless', command: 'cd serverless && npm run build && npm test' }
                    - { task: 'Webapp', command: 'cd webapp && npm run build && npm test' }

        steps:
            - name: Checkout code
              uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: '20'

            - name: Cache node_modules
              uses: actions/cache@v4
              with:
                  path: .
                  key: node-modules-${{ runner.os }}-${{ github.sha }}
                  restore-keys: |
                      node-modules-${{ runner.os }}-

            - name: ${{ matrix.task }}
              run: ${{ matrix.command }}

    cleanup:
        name: Cache Cleanup
        runs-on: ubuntu-latest
        needs: [ci-checks]
        if: always()
        steps:
            - name: Delete workflow cache
              run: |
                  gh cache delete "node-modules-${{ runner.os }}-${{ github.sha }}" --repo ${{ github.repository }} || echo "Cache not found or already deleted"
              env:
                  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Notes:

  • The first job setup installs the project dependencies and creates an entry in the GitHub cache.
  • The second job ci-checks defines a matrix of tasks, each one with a name and a command.
  • Each task restores the code from the GitHub cache and runs its command
  • Unfortunately, there's no way to specify a time-to-live (TTL) for the cache entries. Ideally, I would have set it to 5 minutes as each task takes between 1 and 2 minutes to run. To compensate, the final job calls the gh CLI to flush the just-created cache entry.

Guarding Our Gates: Secure Merges and Designated Reviewers

We want developers' contributions to only go through pull requests (PRs). PRs have the benefits of forcing verification steps, involving recently joined developers, and having seasoned developers share their knowledge.

The main advantage of the Rule Sets over the legacy Branch Protection rule is that one set can cover any branches. In our case, we are only interested in protecting the main and the develop branches.

{
    "name": "Review Requirements",
    "target": "branch",
    "enforcement": "active",
    "conditions": {
        "ref_name": {
            "include": ["refs/heads/main", "refs/heads/develop"],
            "exclude": []
        }
    },
    "rules": [
        {
            "type": "pull_request",
            "parameters": {
                "required_approving_review_count": 1,
                "dismiss_stale_reviews_on_push": true,
                "require_code_owner_review": true,
                "require_last_push_approval": false,
                "required_review_thread_resolution": false,
                "automatic_copilot_code_review_enabled": false,
                "allowed_merge_methods": ["merge", "squash", "rebase"]
            }
        }
    ],
    "bypass_actors": [
        {
            "actor_type": "RepositoryRole",
            "actor_id": 5
        }
    ]
}

This Rule Set definition needs the presence of the CODEOWNERS file to decide who the required approvers are. This file is useful by itself because it helps involve the best reviewer(s) for each code update.

# Global code owners
* @admin-1 @admin-2

# DevOps
/.github @devops-1
/iac/ @devops-1

# Service developers
# /serverless/ @backend-1

# Frontend developers
# /webapp/ @frontend-1

Smart Security Scanning: Open Source Tools for Targeted Code Review

Even with robust branch protection and automated status checks, a critical layer of defense is ensuring the quality and security of the code itself.

My first reflex was to look at the CodeQL offering from GitHub. However, I quickly looked for an alternative because of the steep cost increase it would represent for our team:

  • From $4/user/month for a GitHub Team plan.
  • To $53/user/month with the addition of the Advanced Security.

I then decided to focus on powerful yet cost-effective open-source tools:

  • npm audit
  • ESLint with security plugins, and
  • Custom regular expressions to identify potential vulnerabilities.

To avoid scanning everything every time, the checks are optimized using dorny/paths-filter@v3, allowing us to intelligently target our analysis only to the code that has changed.

name: Dependency & Security Review

on:
    pull_request:
        branches: [develop]
        types: [opened, synchronize, reopened]

permissions:
    contents: read
    checks: write
    pull-requests: read

jobs:
    # Detect which projects have changes
    detect-changes:
        name: Detect Project Changes
        runs-on: ubuntu-latest
        outputs:
            iac: ${{ steps.changes.outputs.iac }}
            serverless: ${{ steps.changes.outputs.serverless }}
            webapp: ${{ steps.changes.outputs.webapp }}
            dependencies: ${{ steps.changes.outputs.dependencies }}
        steps:
            - name: Checkout code
              uses: actions/checkout@v4
              with:
                  fetch-depth: 0

            - name: Detect changes
              uses: dorny/paths-filter@v3
              id: changes
              with:
                  filters: |
                      iac:
                        - 'iac/**'
                        - 'interfaces/environment/**'
                        - 'interfaces/resources/**'
                      serverless:
                        - 'serverless/**'
                        - 'interfaces/**'
                      webapp:
                        - 'webapp/**'
                        - 'interfaces/models/**'
                      dependencies:
                        - '**/package.json'
                        - '**/package-lock.json'
                        - '**/yarn.lock'

    security-audit-iac:
        name: Security Audit - Infrastructure
        runs-on: ubuntu-latest
        needs: detect-changes
        if: needs.detect-changes.outputs.iac == 'true'

        steps:
            ... # skipped

    security-audit-serverless:
        name: Security Audit - Serverless
        runs-on: ubuntu-latest
        needs: detect-changes
        if: needs.detect-changes.outputs.serverless == 'true'

        steps:
            - name: Checkout code
              uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: '20'
                  cache: 'npm'

            - name: Install dependencies
              run: |
                  npm ci
                  cd serverless && npm ci

            - name: Run npm audit (Serverless)
              run: |
                  echo "🔍 Running security audit for Serverless..."
                  cd serverless
                  npm audit --audit-level=moderate --production
              continue-on-error: true

            - name: Run ESLint security check
              run: |
                  echo "🔍 Running ESLint security rules for Serverless..."
                  # Use root-level ESLint to check serverless code
                  npx eslint serverless/src/ --ext .ts,.js --format compact || echo "ESLint found potential issues"
              continue-on-error: true

            - name: Check for known security patterns
              run: |
                  echo "🔍 Checking for common security issues in Serverless..."
                  # Check for hardcoded secrets
                  if grep -r -i "password\|secret\|token\|key" serverless/src/ --exclude-dir=node_modules | grep -v "\.d\.ts" | grep -v "console\.log"; then
                    echo "⚠️  Potential hardcoded secrets found. Please review."
                  else
                    echo "✅ No obvious hardcoded secrets found"
                  fi

                  # Check for SQL injection patterns
                  if grep -r -i "SELECT\|INSERT\|UPDATE\|DELETE" serverless/src/ --exclude-dir=node_modules | grep -v "\.d\.ts" | grep "\+\|concat"; then
                    echo "⚠️  Potential SQL injection patterns found. Please review."
                  else
                    echo "✅ No obvious SQL injection patterns found"
                  fi

    security-audit-webapp:
        name: Security Audit - Webapp
        runs-on: ubuntu-latest
        needs: detect-changes
        if: needs.detect-changes.outputs.webapp == 'true'

        steps:
           ... # skipped

    dependency-analysis:
        name: Dependency Analysis
        runs-on: ubuntu-latest
        needs: detect-changes
        if: needs.detect-changes.outputs.dependencies == 'true'

        steps:
            - name: Checkout code
              uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: '20'
                  cache: 'npm'

            - name: Install dependencies
              run: npm ci

            - name: Analyze root dependencies
              run: |
                  echo "🔍 Analyzing root project dependencies..."
                  npm audit --audit-level=moderate || echo "Audit completed with issues"

                  echo ""
                  echo "📊 Dependency summary:"
                  npm ls --depth=0 || echo "Dependency tree generated"

            - name: Check for license compliance
              run: |
                  echo "🔍 Checking license compliance..."

                  # List all licenses
                  echo "📄 Found licenses:"
                  npm list --depth=0 --json | jq -r '.dependencies | to_entries[] | "\(.key): \(.value.version)"' || echo "License check completed"

                  echo ""
                  echo "ℹ️  Allowed licenses in your workflow: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC"
                  echo "⚠️  Please manually review any licenses not in the allowed list"

            - name: Check dependency freshness
              run: |
                  echo "🔍 Checking for outdated dependencies..."
                  npm outdated || echo "Outdated check completed"

                  echo ""
                  echo "💡 Consider updating outdated dependencies with:"
                  echo "   npm update"
                  echo "   or create a PR to update specific packages"

The Trust Challenge: Safely Reporting Untrusted Infrastructure Previews

Deploying serverless logic or a webapp code can quickly be reverted if something wrong happens. It's a totally different game with the deployed infrastructure.

Branching and Deployment

We use a forking workflow with two main branches in our centralized repository:

develop: This is our primary development branch. Any code merged into develop is automatically deployed to our development environment on AWS. This is where our QA team does their initial testing.

main: This branch represents our production-ready code. What's merged into main is automatically deployed to our production environment on AWS.

We manually merge changes from develop into main only after the QA team has thoroughly tested the build in the development environment and given it their approval.

Developers are working with your their forked repository:

  • Fork and Clone: Start by forking the main repository and then cloning your personal fork to your local machine.
  • Branching for Tasks: To keep your work organized and isolated, create branches out of the develop branch from the main repository.
  • Pushing and Pull Requests: Once changes have been pushed to the fork, create a cross-repository pull request back to the central develop branch.
  • Staying Up-to-Date: To incorporate the latest changes from other developers or after your pull requests have been merged, rebase your local repository with git pull --rebase upstream develop, and update your fork with git push --rebase origin main

The Security Challenge of Infrastructure Code in Forks

When a workflow is created on a PR creation, the code usually runs in the submitter account context thanks to the workflow trigger pull_request.

But sometimes, the workflow needs to use secrets or authorizations that are only available in the context of the main repository. This can be achieved with the trigger pull_request_target.

The risk of this workflow is that the submitter, knowingly or not, may have harmful code running as part of the workflow, to steal credentials or to alter the infrastructure.

Dual-Checkout Pattern for Trusted/Untrusted Code Separation

The first step to prevent harmful side-effects, it to checkout the code of the PR in a separate folder (not yet approved, waiting for merge).

The second step is to run only the code from the main repository (already approved and merged). As you can see in the workflow file below, that mean replacing commands like npx tsc by ../../iac/node_modules/.bin/tsc, for example.

To list what infrastructure changes to expect in the PR, we rely on the AWS CDK tool with the command cdk diff <branch> --output cdk.out --progress=events.

Notes:

  • The workflow only runs when changes are detected in the project /iac.
  • All operations we can't completely control, like npm ci, run in a Docker container.
  • Using --output cdk.out is required otherwise the diffing process will save part of the information in the local directory (untrusted) and another part like the manifest.json in the parent directory (trusted, where no cdk.out folder exists).
  • The cdk-notifier tool is used to expose the upcoming changes in a visual form posted as the comment in the PR.
  • The only risk that remains is a malevolent use of the GITHUB_TOKEN by the cdk-notifier. However, the token is short lived and only have the permissions defined in the workflow (pull-requests: write, etc.).
name: Publish CDK diffs

on:
    pull_request_target: # Run in main repo context for status checks
        branches: [develop]
        types: [opened, synchronize, reopened]
        paths:
            - 'iac/**'
            - '!iac/test/**'

env:
    AWS_REGION: --- # obfuscated
    AWS_ACCOUNT: --- # obfuscated
    BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
    GITHUB_OWNER: ${{ github.repository_owner }}
    GITHUB_REPO: ${{ github.event.repository.name }}
    PULL_REQUEST_ID: ${{ github.event.pull_request.number }}
    UNTRUSTED_CODE_FOLDER: untrusted-pr-code

jobs:
    deploy:
        name: check diff to develop
        runs-on: ubuntu-latest
        permissions:
            id-token: write
            contents: read
            pull-requests: write
            issues: write # For commenting on PRs
            repository-projects: write
        steps:
            - name: Checkout Base Branch (Trusted Workflow Definition and Dependencies)
              uses: actions/checkout@v4
              with:
                  ref: ${{ github.event.pull_request.base.ref }} # Checkout the code of the develop branch

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: '20'
                  cache: 'npm'

            - name: Install Base Branch Dependencies (Trusted CDK from 'develop')
              run: npm ci # This installs your trusted CDK CLI and other base dependencies

            - name: Prepare IaC environment (Trusted CDK)
              working-directory: ./iac
              run: npm ci # This installs your trusted CDK CLI and other base dependencies

            - name: Configure AWS credentials
              uses: aws-actions/configure-aws-credentials@v4
              with:
                  role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT }}:role/github-ci-role
                  aws-region: ${{ env.AWS_REGION }}

            - name: Check caller identity
              run: aws sts get-caller-identity

            - name: Checkout PR Head (Untrusted Code)
              uses: actions/checkout@v4
              with:
                  ref: ${{ github.event.pull_request.head.sha }}
                  path: ./${{ env.UNTRUSTED_CODE_FOLDER }} # Checkout into a distinct sub-directory
                  persist-credentials: false # Crucial: Do not carry over git credentials

            - name: Prepare Base Branch Dependencies (Untrusted Code from PR)
              run: |
                  docker run --rm \
                    -v "$(pwd)/$UNTRUSTED_CODE_FOLDER:/workspace:rw" \
                    -w /workspace \
                    --user $(id -u):$(id -g) \
                    node:20-alpine \
                    npm ci

            - name: Prepare IaC environment (Untrusted Code)
              run: |
                  docker run --rm \
                    -v "$(pwd)/$UNTRUSTED_CODE_FOLDER:/workspace:rw" \
                    -w /workspace/iac \
                    --user $(id -u):$(id -g) \
                    node:20-alpine \
                    npm ci

            - name: Prepare serverless environment
              run: |
                  docker run --rm \
                    -v "$(pwd)/$UNTRUSTED_CODE_FOLDER:/workspace:rw" \
                    -w /workspace/serverless \
                    --user $(id -u):$(id -g) \
                    node:20-alpine \
                    npm ci

            - name: Compile Webapp
              run: |
                  docker run --rm \
                    -v "$(pwd)/$UNTRUSTED_CODE_FOLDER:/workspace:rw" \
                    -w /workspace/webapp \
                    --user $(id -u):$(id -g) \
                    node:20-alpine \
                    sh -c "npm ci && npm run build"

            # Check diff the PR code to develop while using the trusted CDK CLI (from the parent folder)
            # This ensures that the CDK CLI is trusted and not influenced by untrusted code.
            # This is crucial to prevent potential security risks from untrusted code.
            - name: Check diff to develop
              working-directory: ./${{ env.UNTRUSTED_CODE_FOLDER }}/iac
              run: |
                  echo "- Step 1: TSC"
                  ../../iac/node_modules/.bin/tsc
                  echo "- Step 2: EsLint"
                  ../../iac/node_modules/.bin/eslint .
                  echo "- Step 3: CDK diff between PR code and stack 'Develop' live in AWS Cloud"
                  ../../iac/node_modules/.bin/cdk diff Develop --output cdk.out --progress=events &> >(tee cdk.log)

            # cdk-notifier
            - name: Post CDK diff to PR
              run: |
                  echo "Create cdk-notifier report"
                  docker run --rm \
                    -v "$(pwd)/$UNTRUSTED_CODE_FOLDER/iac/cdk.log:/app/cdk.log:ro" \
                    -e GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}" \
                    karlderkaefer/cdk-notifier:latest \
                    --owner $GITHUB_OWNER \
                    --repo $GITHUB_REPO \
                    --token "$GITHUB_TOKEN" \
                    --log-file /app/cdk.log \
                    --tag-id "diff-pr-$PULL_REQUEST_ID-to-develop" \
                    --pull-request-id $PULL_REQUEST_ID \
                    --vcs github \
                    --ci circleci \
                    --template extendedWithResources

Lesson Learned

Setting up secure pipelines is hard. It took me 3 days to reach my goals. Without the help of various AI agents, this exercise could have last a month because even with the help of the agents, there were so many things that can be always improved. Just the setup of the infrastructure diffing with the CDK kept me busy during 2 days...


This content originally appeared on DEV Community and was authored by Dom Derrien


Print Share Comment Cite Upload Translate Updates
APA

Dom Derrien | Sciencx (2025-07-09T18:49:01+00:00) From Chaos to Control: GitHub Rule Sets and Workflows for Safer AWS Deployments. Retrieved from https://www.scien.cx/2025/07/09/from-chaos-to-control-github-rule-sets-and-workflows-for-safer-aws-deployments/

MLA
" » From Chaos to Control: GitHub Rule Sets and Workflows for Safer AWS Deployments." Dom Derrien | Sciencx - Wednesday July 9, 2025, https://www.scien.cx/2025/07/09/from-chaos-to-control-github-rule-sets-and-workflows-for-safer-aws-deployments/
HARVARD
Dom Derrien | Sciencx Wednesday July 9, 2025 » From Chaos to Control: GitHub Rule Sets and Workflows for Safer AWS Deployments., viewed ,<https://www.scien.cx/2025/07/09/from-chaos-to-control-github-rule-sets-and-workflows-for-safer-aws-deployments/>
VANCOUVER
Dom Derrien | Sciencx - » From Chaos to Control: GitHub Rule Sets and Workflows for Safer AWS Deployments. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/07/09/from-chaos-to-control-github-rule-sets-and-workflows-for-safer-aws-deployments/
CHICAGO
" » From Chaos to Control: GitHub Rule Sets and Workflows for Safer AWS Deployments." Dom Derrien | Sciencx - Accessed . https://www.scien.cx/2025/07/09/from-chaos-to-control-github-rule-sets-and-workflows-for-safer-aws-deployments/
IEEE
" » From Chaos to Control: GitHub Rule Sets and Workflows for Safer AWS Deployments." Dom Derrien | Sciencx [Online]. Available: https://www.scien.cx/2025/07/09/from-chaos-to-control-github-rule-sets-and-workflows-for-safer-aws-deployments/. [Accessed: ]
rf:citation
» From Chaos to Control: GitHub Rule Sets and Workflows for Safer AWS Deployments | Dom Derrien | Sciencx | https://www.scien.cx/2025/07/09/from-chaos-to-control-github-rule-sets-and-workflows-for-safer-aws-deployments/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.