The GitHub Actions Pipeline: Trunk-Based Dev, Release Tags, and Ordered CDK Deploys
CDK Series — Part 5 of 6 | The dependency map you built in Article 1 becomes three words in YAML: needs: [deploy-data]. Here’s the full pipeline.
Claude writes individual GitHub Actions jobs correctly. What it doesn’t know is your deployment model — trunk-based development, release-tag-driven production deploys, selective stack deployment when only app code changes. Without that context, it writes a monolithic “deploy everything on every push” pipeline. That’s expensive, slow, and dangerous for stateful infrastructure.
This is where Articles 1–4 converge. The stack dependency ordering you established — NetworkStack → DataStack → AppStack — becomes three GitHub Actions needs: declarations. The CLAUDE.md rules from every previous article become conditional logic in YAML. The Aurora checklist from Article 4 runs as a PR gate on the cdk diff output. Claude can write the individual pieces. Without your deployment model in context, it cannot wire them together correctly.
This article is the pipeline specification. The YAML is real — copy it, adapt the stack names, wire in your OIDC role ARN.
Two Triggers, Two Purposes
The design decision Claude consistently misses: infrastructure deploys and application deploys need separate triggers.
on:
push:
branches: [main]
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
pull_request:
branches: [main]Trigger 1 — push to main runs on every merge:
- Lint, typecheck, unit tests
cdk synth— validates CDK can synthesize without errorscdk diffcomment posted directly to the PR- Does NOT deploy to production
- Does NOT build Docker images (expensive on every commit)
Trigger 2 — push tags v* is an intentional production release:
- Builds Docker image → pushes to ECR with a versioned tag
- Deploys stacks in dependency order
- Infrastructure stacks only redeploy if CDK code changed (paths-filter)
- Purpose: deliberate, traceable production releases — not accidental pushes from any merged PR
Trunk-based development means every PR merge goes to main. If you deploy to production on every merge, you create two problems simultaneously: you pressure engineers to keep main perpetually release-ready at every commit, and you generate production incidents from incremental changes that weren’t intended as complete features. Tags are a deliberate decision — the engineer tags when the feature set is complete and tested, not when the PR merges.
Claude defaults to on: push: branches: [main] wired directly to cdk deploy --all. It maps a single trigger to a single undifferentiated action. That's the monolith pattern — and it makes every merge a potential production incident.
OIDC — The Credential Model Claude Gets Wrong
Claude’s default: store AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub Secrets. This is a long-lived credential. If GitHub is compromised — or if anyone with repo access accidentally exposes a secret in workflow logs — your AWS account is compromised for as long as those keys remain valid.
The correct approach is OIDC (OpenID Connect). GitHub generates short-lived tokens for each workflow run. No credentials are stored anywhere. Tokens expire when the job ends.
// CDK: set up OIDC provider and scoped deployment role
const githubOidcProvider = new iam.OpenIdConnectProvider(this, 'GithubOidc', {
url: 'https://token.actions.githubusercontent.com',
clientIds: ['sts.amazonaws.com'],
});
const deployRole = new iam.Role(this, 'GithubActionsDeployRole', {
assumedBy: new iam.OpenIdConnectPrincipal(githubOidcProvider, {
StringLike: {
'token.actions.githubusercontent.com:sub':
`repo:${githubOrg}/${repoName}:ref:refs/tags/v*`,
},
}),
description: 'Assumed by GitHub Actions release pipeline only',
maxSessionDuration: Duration.hours(1),
inlinePolicies: {
CdkDeployPolicy: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
actions: ['sts:AssumeRole'],
resources: [`arn:aws:iam::${this.account}:role/cdk-*`],
}),
],
}),
},
});Two security constraints worth understanding:
The sub condition — ref:refs/tags/v* — scopes this role to release tags only. A push to main cannot assume the production deploy role. A PR build cannot assume it. Only a workflow triggered by a v* tag push can call STS and get credentials. Branch pushes are structurally excluded from production deployments.
The resources constraint — cdk-* — limits what the role can further assume. Claude writes "*" on STS AssumeRole by default. That's privilege escalation to any role in the account. Scoping to cdk-* limits the role to CDK bootstrap execution roles only — enough to deploy, not enough to do anything else.
The Five-Job Pipeline
The full pipeline skeleton, structured to show the decisions that matter:
jobs:
validate:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build && npm test
- run: npx cdk synth
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.CDK_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- uses: corymhall/cdk-diff-action@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
build-image:
if: startsWith(github.ref, 'refs/tags/v')
needs: [validate]
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- uses: docker/metadata-action@v5
id: meta
with:
tags: type=semver,pattern={{version}} # never 'latest'
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.CDK_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr-login
- uses: docker/build-push-action@v5
with:
push: true
tags: ${{ steps.ecr-login.outputs.registry }}/${{ env.ECR_REPO }}:${{ steps.meta.outputs.version }}
deploy-network:
if: startsWith(github.ref, 'refs/tags/v')
needs: [validate]
steps:
- run: npx cdk deploy NetworkStack --require-approval never
deploy-data:
needs: [deploy-network]
steps:
- run: npx cdk deploy DataStack --require-approval never
deploy-app:
needs: [deploy-data, build-image]
env:
IMAGE_TAG: ${{ needs.build-image.outputs.image-tag }}
steps:
- run: npx cdk deploy AppStack --require-approval neverWhy deploy-app Needs Both deploy-data AND build-image
deploy-app declares two needs: entries — deploy-data and build-image. This single line is the dependency map from Article 1 made executable in YAML.
deploy-data ensures the Aurora cluster and its security groups exist before AppStack deploys. AppStack imports CloudFormation outputs from DataStack — the security group ID, the secret ARN. If DataStack hasn't completed, those outputs don't exist and AppStack fails.
Get George Taskos’s stories in your inbox
Join Medium for free to get updates from this writer.
build-image ensures the new Docker image is in ECR before AppStack references it. The image tag flows as a job output — needs.build-image.outputs.image-tag — and is injected into the CDK deploy as IMAGE_TAG. This connects back to Article 3's rule: image tags are versioned semver strings from the git tag, never latest.
GitHub Actions runs deploy-data and build-image in parallel. Both must complete before deploy-app starts. The pipeline enforces the architecture at the execution level, not just as documentation.
Selective Deployment — Only Deploy What Changed
Claude writes cdk deploy --all everywhere. In production this is wrong. Every release redeploys your VPC and Aurora cluster even when only app code changed. Aurora deploy takes 5–10 minutes on its own.
Use dorny/paths-filter to make infrastructure deploys conditional:
deploy-network:
if: startsWith(github.ref, 'refs/tags/v')
needs: [validate]
steps:
- uses: dorny/paths-filter@v3
id: changed
with:
filters: |
network:
- 'lib/network-stack.ts'
- 'lib/constructs/vpc/**'
- name: Deploy NetworkStack
if: steps.changed.outputs.network == 'true'
run: npx cdk deploy NetworkStack --require-approval neverSame pattern for deploy-data — conditional on changes to lib/data-stack.ts or Aurora construct files.
For an app-only release — a new feature, a bug fix, a Docker image rebuild — only validate, build-image, and deploy-app run. NetworkStack and DataStack skip entirely. Pipeline time drops from 15+ minutes to approximately 3 minutes.
That’s not a minor optimization. It’s the difference between a deployment that blocks your afternoon and one that completes before you finish reviewing the PR.
cdk diff in PRs — The IaC Safety Net
corymhall/cdk-diff-action posts the CloudFormation diff as a PR comment before any merge. This is the IaC equivalent of a code diff — engineers see exactly what will change in the infrastructure, in the PR, before approval.
The action runs in the validate job on every push to main and every pull request. It shows additions in green, modifications in yellow, replacements in orange.
Replacements are the human stop signal. Any diff that shows:
- DatabaseCluster replacement → CloudFormation deletes the old cluster, creates a new empty one. Every row gone. There is no rollback — only a restore from your last backup.
- SecurityGroup replacement → traffic drops during the replacement window. Every resource attached to the old security group loses network access until it attaches to the new one.
- IAM Role replacement → permission gap while the new role is created and attached. Running tasks lose access to secrets, S3 buckets, queues.
These don’t fail the pipeline. They surface the risk on the PR. A human reviews the cdk diff output and decides whether to approve the merge. This is where Article 4's 10-point Aurora PR checklist runs — not as manual CDK TypeScript inspection, but as structured review of the diff the pipeline posts for you.
Drift Detection — cdk drift (May 2025)
AWS CDK shipped cdk drift in May 2025. It detects out-of-band changes — someone edited a security group rule in the console, manually updated an environment variable, changed a parameter to resolve an incident. Changes that diverged your production infrastructure from your CDK code.
name: Weekly Drift Detection
on:
schedule:
- cron: '0 6 * * 1' # Monday 6AM UTC
jobs:
drift-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.CDK_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- run: npx cdk drift NetworkStack DataStack AppStackAny stack where live AWS state diverges from CDK code fails the check. Run it weekly. Wire the failure to a GitHub issue or a Slack alert.
“I just fixed it in the console” is common incident response. It also creates invisible divergence between your code and your infrastructure — overwritten silently on the next CDK deploy. cdk drift makes it visible on Monday morning, before it becomes Friday's outage.
CLAUDE.md for the Pipeline
## GitHub Actions Pipeline Rules
1. Use OIDC for AWS credentials — never store AWS_ACCESS_KEY_ID in GitHub Secrets
2. Never `cdk deploy --all` — deploy stacks by name with selective conditions
3. Dependency ordering via `needs:` — deploy-network before deploy-data before deploy-app
4. Always include `cdk synth` in the validate job before any deploy
5. Always include `cdk diff` in PR validate — flag replacements as human review required
6. Release tags (v*) drive production deploys, not push to main
7. IMAGE_TAG passed as job output from build-image — never hardcoded
8. OIDC role scoped to release tags only: ref:refs/tags/v*These rules encode the deployment model that Claude cannot infer from your codebase. Without them, Claude writes a functional pipeline that violates every constraint above. With them, Claude works inside your architecture instead of around it.
Add these to the same CLAUDE.md that carries the rules from Articles 1–4. The file grows with the series — each article adds the constraints for its domain. By Article 6, Claude has a complete specification of the stack, the deployment model, and the safety gates. That context is what separates Claude Code generating working infrastructure from Claude Code generating production infrastructure.
The Series
Add the pipeline rules above to your project root CLAUDE.md alongside the rules from Articles 1–4. Article 6 shows the complete 15-rule combined file.
- The CDK Mental Model — the 6 CDK defaults, CLAUDE.md foundation, dependency map
- Private by Default — VPC, subnets, security group chain
- ECS/Fargate with Claude — task definitions, secrets, autoscaling
- Aurora PostgreSQL 16 — the database Claude shouldn’t touch alone
- The GitHub Actions Pipeline — this article
- The Full Stack Loop — running everything together without drift
CDK Series — Part 5 of 6
← Part 4: Aurora PostgreSQL 16
→ Part 6: The Full Stack Loop








