โ† Workshops ยท Part 9 ยท CI/CD

CI/CD & Releases with GitHub Actions

You can build and deploy anything from Parts 1โ€“6. Now automate it: build, check, and ship on every push. One pipeline โ€” preview environments per PR, keyless auth to AWS, protected production, and a release you can roll back.

๐Ÿ“ Test yourself โ†“
18 min readAutomationRead or present

What you'll build

A single, reusable GitHub Actions pipeline that turns a git push into a safe deploy. Every pull request gets checked and previewed; every merge to main ships; every release is a tagged, reproducible artifact you can roll back. Then you'll wire that pipeline to each stack from the series โ€” the Vercel frontend, the Node.js API, the Python SAM service, and the ECS Fargate container.

๐Ÿ”

The loop: push โ†’ CI checks โ†’ preview env โ†’ merge โ†’ deploy โ†’ release. Build it once, point it at any stack, and stop deploying by hand.

CI vs CD โ€” and the environments in between

CI (Continuous Integration) runs your checks โ€” lint, types, tests, build โ€” on every change, so broken code never reaches main. CD (Continuous Delivery/Deployment) takes what passed and ships it to an environment. The environments are just named copies of your app, each with its own data and config:

EnvironmentWho sees itDeployed when
Local (dev)Just youAlways โ€” it's your machine (Parts 1โ€“6)
PreviewYour team, per PRAutomatically on every pull request
StagingYour teamOn merge to main (optional)
ProductionEveryoneOn a release โ€” behind an approval
๐ŸŽฏ

At a minimum, run two deployed environments: a Dev environment (shared โ€” where merged work lands and the team tests) and Production. Everything else โ€” a per-ticket preview env on each PR, a staging rehearsal โ€” you add as the team grows. Never ship straight to prod with nothing in between.

The pieces of a GitHub Actions pipeline

๐Ÿ“„

Workflow

A YAML file in .github/workflows/. One file per job-to-do (ci, deploy, release).

โšก

Trigger (on:)

What starts it โ€” a pull request, a push to main, or a version tag.

๐Ÿงฑ

Jobs & steps

A job runs on a fresh VM; steps are the commands and reusable uses: actions inside it.

๐Ÿ”

Secrets & OIDC

Tokens and cloud credentials โ€” stored in GitHub, or fetched keylessly via OIDC.

๐Ÿšฆ

Environments

Named deploy targets (production) with their own secrets and required reviewers.

๐Ÿท๏ธ

Releases & tags

A v1.2.0 tag marks a versioned, reproducible artifact to ship and roll back to.

Anatomy of a workflow file

# .github/workflows/ci.yml
name: CI
on:                         # the trigger
  pull_request:             # run on every PR
  push:
    branches: [main]        # and on merges to main
jobs:
  check:
    runs-on: ubuntu-latest  # a fresh VM
    steps:
      - uses: actions/checkout@v4        # get the code
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci                       # exact, lockfile-based install
      - run: npm run lint
      - run: npm run build                # a green build is the gate
๐Ÿ’ก

Read it top-down: when (on) โ†’ where (runs-on) โ†’ what (steps). Every workflow in this part is a variation on this shape.

Step 1

Continuous Integration: check every PR

The workflow above is your CI. It runs on each pull request and each push to main. To make it mean something, turn on branch protection so a red check blocks the merge.

๐Ÿ›ก๏ธ

In GitHub โ†’ Settings โ†’ Branches: protect main, require the check job to pass, and require a pull request before merging. Now "it built on my machine" isn't the standard โ€” "it built in CI" is.

Unit, integration & end-to-end tests

A green build proves the code compiles โ€” tests prove it works. Think of them as a pyramid: many fast tests at the bottom, a few slow ones at the top. Each layer catches a different kind of bug:

LayerTestsSpeed & countTools
UnitOne function/component in isolation โ€” logic, edge casesMilliseconds ยท manyVitest / Jest ยท pytest
IntegrationPieces together โ€” a route + its real database, a repositorySeconds ยท someSupertest ยท pytest + test DB
End-to-endThe whole app as a user โ€” click, type, assert the screenSlow ยท fewPlaywright / Cypress

Unit and integration tests run in CI on every PR โ€” they're fast and need no deploy. Add them to the check job:

      - run: npm ci
      - run: npm run lint
      - run: npm run test:unit          # fast, isolated โ€” runs on every PR
      - run: npm run test:integration   # spins up a test DB in a service container
      - run: npm run build

End-to-end tests run against a real deploy โ€” point Playwright at the preview URL from the next step, so you test the app as shipped, not a mock:

  e2e:
    needs: deploy-preview             # wait for the preview URL
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx playwright install --with-deps
      - run: npx playwright test
        env: { BASE_URL: "${{ needs.deploy-preview.outputs.url }}" }
๐Ÿค–

Let Claude Code write them: "add unit tests for the notes service covering the empty, valid, and invalid cases; an integration test that hits POST then GET /api/notes against a test database; and a Playwright e2e that adds a note and asserts it appears." Then have it wire the jobs into CI.

Step 2

Preview environments, one per pull request

A preview is a live, disposable copy of your app for a single PR โ€” review the change at a real URL before it merges. For the frontend this is free and automatic: connect the repo in Vercel and every PR gets its own URL.

๐Ÿ”Ž

For backends, a full per-PR environment (its own database + deploy) is powerful but advanced โ€” separate SAM stacks or ECS services per PR, torn down on merge. Start by previewing the frontend; add backend previews when the team needs them.

Step 3

Authenticate to the cloud without storing keys (OIDC)

Deploys need credentials โ€” but long-lived AWS keys in GitHub secrets are a liability. Use OIDC instead: the workflow requests a short-lived token and assumes an IAM role you've scoped to your repo. Nothing secret is stored.

# inside a deploy job
permissions:
  id-token: write     # let the job request an OIDC token
  contents: read
steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
      aws-region: eu-west-1
๐Ÿ”‘

One-time setup: add GitHub as an OIDC identity provider in AWS IAM, and create a role whose trust policy allows only your repo (e.g. repo:your-org/your-repo:ref:refs/heads/main). For Vercel, a single VERCEL_TOKEN repo secret is enough.

Step 4

Protect production with environments & approvals

Wrap the real deploy in a GitHub Environment named production. Give it a required reviewer, and the job pauses until someone approves โ€” plus its secrets are only readable by jobs targeting that environment.

# in the deploy job
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # pauses here for approval
    steps:
      - # ... deploy steps

Credentials & secrets, handled safely

A pipeline needs credentials, and the golden rule is simple: secrets never live in your code or your repo โ€” they live in the platform, injected at run time. It helps to separate two kinds:

KindExamplesWhere it lives
Deploy-timeCloud access, VERCEL_TOKEN, registry loginGitHub โ€” Actions secrets (prefer OIDC over stored keys)
Run-timeDatabase URL, API keys the app usesVercel env vars, or AWS SSM / Secrets Manager

Reference a GitHub secret in a workflow with ${{ secrets.NAME }} โ€” its value is automatically masked in logs:

    steps:
      - run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
      - run: echo "$DB_URL"        # prints *** โ€” masked, never the value
        env: { DB_URL: "${{ secrets.DATABASE_URL }}" }
๐Ÿ”

In the repo, ship a template, not the values. Commit a .env.example listing the names of the variables an app needs, keep the real .env in .gitignore, and let each environment supply its own. Ask Claude Code: "scan the repo for committed secrets and move them to env vars."

โš ๏ธ

If a secret ever lands in git, rotate it โ€” don't just delete the commit. Once pushed, assume it's compromised: revoke and reissue the token, then scrub the history. Deleting the file alone leaves it in every clone and the reflog.

Deploy the frontend (Parts 1โ€“3) โ€” Vercel

The easy path has no YAML at all: connect the repo in Vercel and its Git integration handles both halves โ€” a preview URL per PR, production on merge to main. That's continuous deployment out of the box.

# optional: deploy from Actions instead of Vercel's Git integration
- run: npm i -g vercel
- run: vercel pull --yes --environment=production --token=$VERCEL_TOKEN
- run: vercel build --prod --token=$VERCEL_TOKEN
- run: vercel deploy --prebuilt --prod --token=$VERCEL_TOKEN
โœ…

Use the Git integration unless you need custom steps (a monorepo, extra checks, or non-Vercel hosting). Don't hand-build what the platform already does well.

Deploy a static frontend โ€” S3 + CloudFront

The AWS alternative to Vercel from Part 3: S3 stores the built files and CloudFront is the CDN that serves them worldwide over HTTPS. The pipeline is three moves โ€” build, sync to the bucket, invalidate the cache:

# .github/workflows/deploy-s3.yml
name: Deploy (S3 + CloudFront)
on:
  push:
    branches: [main]
permissions:
  id-token: write
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run build                 # produces static files (e.g. out/ or dist/)
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-west-1
      - run: |
          aws s3 sync ./out s3://my-site-bucket --delete   # upload; remove stale files
          aws cloudfront create-invalidation \
            --distribution-id ${{ vars.CF_DISTRIBUTION_ID }} \
            --paths "/*"                                     # serve the new build now
โš ๏ธ

S3 + CloudFront serves static files only. It's perfect for a static export or a client-side SPA. A Next.js app with Server Components, Server Actions, or the Route Handlers from Part 4 isn't static โ€” deploy that on Vercel, AWS Amplify Hosting, or OpenNext-on-Lambda instead.

Deploy the Node.js API (Part 4) โ€” same pipeline

Route Handlers ship with the Next.js app, so there's nothing extra to deploy โ€” the frontend pipeline already carries your API. Just make sure CI runs any API tests, and that production points at a real database, not the in-memory array from Part 4.

๐Ÿ—„๏ธ

Config, not code: the database URL and secrets belong in environment variables (Vercel project settings / GitHub environment), never committed. Same build, different env per environment.

Deploy the Python API (Part 5) โ€” SAM

# .github/workflows/deploy-sam.yml
name: Deploy (SAM)
on:
  push:
    branches: [main]
permissions:
  id-token: write
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-west-1
      - uses: aws-actions/setup-sam@v2
      - run: sam build
      - run: sam deploy --no-confirm-changeset --no-fail-on-empty-changeset
๐Ÿงฑ

Safe by design: SAM deploys through CloudFormation, so a failed deploy rolls the stack back automatically. Run sam validate and sam build in CI on PRs to catch template errors before they reach AWS.

Deploy the container (Part 6) โ€” ECS Fargate

For a container, the image is the artifact โ€” so building it is its own job. Build and push once, tagged by the commit SHA; then a separate deploy job points the service at that exact image. Never rebuild at deploy time โ€” deploy the thing you already built and tested.

# .github/workflows/deploy-ecs.yml
name: Deploy (ECS)
on:
  push:
    branches: [main]
permissions:
  id-token: write
  contents: read

jobs:
  build:                       # build the image ONCE โ€” this is the artifact
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.push.outputs.image }}
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-west-1
      - uses: aws-actions/amazon-ecr-login@v2
        id: ecr
      - id: push
        run: |
          IMAGE=$REGISTRY/notes-api:$GITHUB_SHA   # tag with the commit SHA
          docker build -t $IMAGE .
          docker push $IMAGE
          echo "image=$IMAGE" >> $GITHUB_OUTPUT
        env: { REGISTRY: "${{ steps.ecr.outputs.registry }}" }

  deploy:                      # deploy THAT image โ€” no rebuild
    needs: build
    runs-on: ubuntu-latest
    environment: production    # pauses for approval
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-west-1
      - uses: aws-actions/amazon-ecs-render-task-definition@v1
        id: taskdef
        with:
          task-definition: task-definition.json
          container-name: notes-api
          image: ${{ needs.build.outputs.image }}   # the built artifact
      - uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.taskdef.outputs.task-definition }}
          cluster: prod
          service: notes-api
          wait-for-service-stability: true          # fail if it won't stay up
๐Ÿณ

Build once, deploy that: the build job produces a SHA-tagged image and hands it to deploy, which renders a new task-definition revision pointing at it. Rollback is deterministic โ€” re-deploy the previous SHA's revision. Nothing is rebuilt in the deploy path.

๐Ÿงช

Catch Dockerfile breaks early: add a docker build . step to your CI on pull requests (no push). If the image can't build, the PR goes red โ€” long before it reaches main.

Docker + ECR

Building & publishing the image to ECR

The build job is the heart of a container pipeline โ€” get its details right and everything downstream (ticket envs, promotion, rollback) becomes deterministic. Four things separate a toy build from a production one:

# the build job โ€” layer caching + an immutable SHA tag
- uses: aws-actions/amazon-ecr-login@v2
  id: ecr
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ${{ steps.ecr.outputs.registry }}/notes-api:${{ github.sha }}
    cache-from: type=gha           # reuse layers from previous runs
    cache-to: type=gha,mode=max    # save layers for the next run
๐Ÿ”’

No registry password anywhere. amazon-ecr-login exchanges your OIDC role for a short-lived token โ€” the same keyless auth as the rest of the pipeline. Grant the role ecr:GetAuthorizationToken plus push/pull on that one repo, nothing more.

The flow

The deployment flow โ€” how it really ships

The per-stack sections above show the simplest single-environment deploy. Here's how the JunctionNet and CargoNaut services actually run it: three tiers, three triggers, each promoting the same commit further along.

TriggerEnvironment(s)Gate
Open / update a PRDev ticket env โ€” one isolated, disposable stack per ticketCI green; torn down on close
Merge to mainDev Integration + StagingAutomatic โ€” both deploy on every merge
Publish a Release (vX.Y.Z)ProductionRequired reviewer approves
PR  โ”€โ”€โ–ถ  dev ticket env  (pr-123)          # review the change live, in isolation
 โ”‚
 merge to main
 โ–ผ
dev-integration  +  staging                # the merged trunk, exercised together
 โ”‚
 publish a GitHub Release  (v1.4.0)
 โ–ผ
production                                 # the same tested artifact, on approval
๐Ÿงญ

One commit, promoted โ€” never rebuilt. The artifact built for the ticket env is the same one that reaches staging and production; each tier just points a new environment at it. A release is a promotion decision, not a fresh build.

Tier 1 ยท per PR

Dev ticket environments

A ticket environment is a full, disposable copy of the backend for one PR/ticket โ€” jnet-1234 โ€” so reviewers hit a real running API, not a mock. It stands up when the PR opens and is torn down when the PR closes. Both backend options do this; only the deploy verb differs:

# .github/workflows/ticket-env.yml โ€” Lambda (SAM): an isolated stack per ticket
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]
jobs:
  up:
    if: github.event.action != 'closed'
    environment: dev
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with: { role-to-assume: <deploy-role>, aws-region: eu-west-1 }
      - uses: aws-actions/setup-sam@v2
      - run: sam build
      - run: |                                   # a whole stack, namespaced by PR
          sam deploy --no-confirm-changeset --no-fail-on-empty-changeset \
            --stack-name notes-api-pr-${{ github.event.number }} \
            --parameter-overrides Stage=pr-${{ github.event.number }}
  down:
    if: github.event.action == 'closed'
    steps:
      - run: sam delete --no-prompts \
          --stack-name notes-api-pr-${{ github.event.number }}
# .github/workflows/ticket-env.yml โ€” Container (ECS): a service per ticket
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]
jobs:
  up:
    if: github.event.action != 'closed'
    steps:
      - # build+push image :$GITHUB_SHA, then create/update an ECS service
      - # notes-api-pr-${{ github.event.number }} in the shared "ticket" cluster,
      - # behind an ALB host rule  pr-<n>.tickets.example.com โ†’ its target group
  down:
    if: github.event.action == 'closed'
    steps:
      - run: |                                    # tear it ALL down on merge/close
          aws ecs delete-service --cluster ticket \
            --service notes-api-pr-${{ github.event.number }} --force

Name the environment from the ticket

Adopt a branch convention that carries the ticket number โ€” e.g. JNET-1234 or feature/JNET-1234-add-search. The workflow reads that number off the branch and uses it as the env's stable subdomain, so the ticket, its branch, and its live URL all share one identifier โ€” and a reviewer can jump straight from the JIRA ticket to the running environment:

# derive a stable env name from the ticket number in the branch
- id: env
  run: |
    TICKET=$(echo "$HEAD" | grep -oiE 'jnet-[0-9]+' | head -1)
    echo "name=${TICKET:-pr-${{ github.event.number }}}" >> $GITHUB_OUTPUT
  env: { HEAD: "${{ github.head_ref }}" }
# โ†’ service   notes-api-${{ steps.env.outputs.name }}
# โ†’ dynamic DNS   jnet-1234.tickets.example.com   (one record per ticket)
๐ŸŽŸ๏ธ

How JunctionNet uses it: each service (admin-api, organizer-api, โ€ฆ) has make ticket NAME=jnet-1234 / destroy-ticket targets that stand up an isolated stack per ticket on the dev account โ€” a reviewer tests the real API before merge, then it's destroyed automatically.

Tier 2 ยท merge to main

Merge to main โ†’ Dev Integration + Staging

When a PR merges, its ticket env is destroyed and the merged commit is deployed to two shared, non-prod environments at once: Dev Integration (where all merged work meets and integration/e2e suites run) and Staging (a production-shaped rehearsal). No approval โ€” this is the automatic trunk deploy.

# .github/workflows/cd.yml โ€” on merge: dev-integration + staging (SAM)
on:
  push: { branches: [main] }
jobs:
  deploy:
    strategy:
      matrix:
        stage: [dev-integration, staging]      # both, in parallel
    environment: ${{ matrix.stage }}
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with: { role-to-assume: <deploy-role>, aws-region: eu-west-1 }
      - uses: aws-actions/setup-sam@v2
      - run: sam build
      - run: sam deploy --config-env ${{ matrix.stage }} \
          --no-confirm-changeset --no-fail-on-empty-changeset
# on merge: build ONCE, deploy that image to dev-integration + staging (ECS)
on:
  push: { branches: [main] }
jobs:
  build:                                         # build + push :$GITHUB_SHA once
    outputs: { image: ${{ steps.push.outputs.image }} }
    steps: [ # docker build + push to ECR โ€ฆ ]
  deploy:
    needs: build
    strategy:
      matrix:
        stage: [dev-integration, staging]
    environment: ${{ matrix.stage }}
    steps:
      - uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          cluster: ${{ matrix.stage }}
          service: notes-api
          # render task def with needs.build.outputs.image (the SAME image)
          wait-for-service-stability: true
Tier 3 ยท release

Create a release โ†’ Production

Production isn't triggered by a merge โ€” it's triggered by publishing a GitHub Release (a vX.Y.Z tag). That deploys the exact commit the release points at โ€” the one already proven in staging โ€” to production, gated by a required reviewer on the production environment.

# .github/workflows/release.yml โ€” Production on a published release (SAM)
on:
  release:
    types: [published]        # tag vX.Y.Z and publish the release
jobs:
  production:
    environment: production   # โ† required reviewer approves here
    steps:
      - uses: actions/checkout@v4
        with: { ref: ${{ github.event.release.tag_name }} }   # the tagged commit
      - uses: aws-actions/configure-aws-credentials@v4
        with: { role-to-assume: <prod-deploy-role>, aws-region: eu-west-1 }
      - uses: aws-actions/setup-sam@v2
      - run: sam build
      - run: sam deploy --config-env production \
          --no-confirm-changeset --no-fail-on-empty-changeset
# Production on a published release โ€” deploy the release's image (ECS)
on:
  release:
    types: [published]
jobs:
  production:
    environment: production   # โ† required reviewer approves here
    steps:
      - uses: actions/checkout@v4
        with: { ref: ${{ github.event.release.tag_name }} }
      - uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          cluster: prod
          service: notes-api
          # image tag = the release commit's SHA โ€” the SAME artifact staging ran
          wait-for-service-stability: true
๐Ÿš€

The shape, end to end: PR โ†’ a throwaway ticket env; merge โ†’ dev-integration + staging automatically; a release โ†’ production on approval. One commit is promoted the whole way โ€” Lambda deploys it with sam deploy --config-env, the container deploys the same SHA-tagged image. The semver & changelog hygiene below is what makes each release meaningful.

Reference

How the API pipeline is actually wired

The three tiers above map onto a concrete set of GitHub Actions workflows. Each API repo keeps thin workflow files that call shared reusable workflows in an org-level junctionnet/.github repo โ€” so every service ships identically, and a pipeline fix lands everywhere at once.

# .github/workflows/api-main.yml (in every service repo) โ€” thin wrapper
name: Release Integration
on:
  push: { branches: [ main, "release/*" ] }
jobs:
  release:
    uses: junctionnet/.github/.github/workflows/api-release.yml@main   # the shared pipeline
    secrets: inherit
WorkflowTriggerWhat it does
CI / Ticket Deploy
ci.yml โ†’ api-ci
PR to mainLint + unit/integration tests, deploy the PR's ticket env, run Playwright e2e against it.
Release Integration
api-main.yml โ†’ api-release
push to main / release/*The integration pipeline โ€” provision infra โ†’ migrate DB โ†’ deploy โ†’ map API โ†’ e2e.
Infra Provisioning
infrastructure.yml
manual (env, plan/apply)Terragrunt plan/apply. Also runs as a job inside the Release Integration & Production pipelines.
DB Migrations
db-migrations.yml
manual + in-pipelineAlembic migrations for the target stage.
API Mappings
api-mappings.yml
manual + in-pipelineRegisters the service's base-path mapping on the shared API Gateway custom domain.
Playwright E2E
playwright.yml
manual (dev/cloud/tech)Re-run e2e against a live env without redeploying (PR e2e already runs in CI).
Create Release
release-tag-create.yml
manual (patch/minor/major)Bumps the version and creates the vX.Y.Z tag.
Deploy Release (Production)
production-tech.yml โ†’ api-production
push tag v*Deploys the tagged release to production, behind the production environment's approval.
Remove Env
remove-env.yml
manual (ticket) + sweepTears down a ticket env by its jnet-1234 id (plus a bulk sweep of stale ones).

The Release Integration flow (on merge to main)

The shared api-release workflow runs the stages in order โ€” a red stage stops the pipeline before anything downstream:

push to main
  โ–ผ
infra (Terragrunt)  โ†’  db migrations (Alembic)  โ†’  deploy (SAM)  โ†’  api mappings  โ†’  e2e (Playwright)
  provision the       apply pending schema        push the       attach the        exercise the
  service's infra     changes for the stage       new code       base-path on the  live API before
                      (before the deploy)                         custom domain     the release

What each stage is

๐ŸŒ

Stages & environments. Non-prod stages are dev (integration) and cloud (staging); production is tech. They map to two GitHub Environments โ€” dev (open) and production (reviewer-gated) โ€” each holding its own secrets. That's the "at least Dev + Production" minimum, made concrete.

โ™ป๏ธ

Why reusable workflows. The per-repo files (ci.yml, api-main.yml, production-tech.yml, โ€ฆ) are ~10 lines each; all the real logic lives in junctionnet/.github. Fix the deploy once there and every API โ€” admin, organizer, documents, connectors โ€” inherits it on the next run.

Cut a release โ€” tags, versions, changelog

A release is a named point in history you can ship and return to. Trigger deploys from a version tag, build the artifact once, and deploy that exact thing everywhere โ€” never rebuild per environment.

# .github/workflows/release.yml
on:
  push:
    tags: ['v*.*.*']    # e.g. v1.4.0 โ†’ build once, then deploy

When it breaks: rolling back

โ–ฒ

Vercel

Instant Rollback โ€” promote a previous deployment from the dashboard or CLI. Seconds.

ฮป

SAM / Lambda

Re-deploy the previous tag; failed deploys auto-roll-back the CloudFormation stack.

๐Ÿณ

ECS Fargate

Point the service at the previous task-definition revision (the prior image SHA).

๐Ÿ—„๏ธ

Databases

The hard part โ€” migrations aren't auto-reversible. Write them backward-compatible.

โš ๏ธ

Rollback is a plan, not a hope. Because you tagged every build, "go back" is deterministic. But a schema migration can't always be undone โ€” deploy DB changes separately and keep them compatible with the previous version.

A CLAUDE.md for CI/CD

# CLAUDE.md โ€” CI/CD

## Pipeline
- CI (.github/workflows/ci.yml): PR + push to main โ†’ install, lint, build/test.
- Deploy: push to main โ†’ production, gated by the `production` environment.
- Release: tag vX.Y.Z โ†’ build once, deploy that artifact.

## Environments
- preview  โ†’ per-PR (Vercel auto).  production โ†’ approved deploy.
- Config & secrets live in GitHub environments / Vercel โ€” never in code.

## Cloud auth
- AWS via OIDC role-to-assume (no stored keys). Vercel via VERCEL_TOKEN secret.

## Conventions
- Tag images with the commit SHA. Keep DB migrations backward-compatible.
- Least-privilege IAM for the deploy role.

## Verify
- A change is done when CI is green AND the preview URL shows it working.

Let Claude Code build your pipeline

Describe the pipeline the way you'd brief an engineer โ€” the triggers, the gates, how it authenticates, and how you'll verify โ€” then let Claude Code write the YAML:

> Add a CI/CD pipeline in .github/workflows/ for this repo:
  - CI on every PR and push to main: install, lint, typecheck, build.
    It must block merge when red.
  - Deploy to production on push to main, gated by a GitHub
    Environment "production" with a required reviewer.
  - Authenticate to AWS with OIDC (role-to-assume) โ€” no long-lived
    keys in secrets.
  - Build the image/bundle once and deploy that same artifact; tag
    images with the commit SHA.
  Keep secrets in GitHub, and show me each workflow plus the plan/diff
  step before anything deploys.
Check yourself

Quiz โ€” 25 questions

Answer every question, then submit to see your score and the correct answers.

Want your deploys automated?

JunctionNet ships production software on GitHub Actions every day โ€” preview environments, OIDC to AWS, protected releases, and rollbacks that work. Reach out for help wiring yours, or a team workshop.

โœ‰๏ธ Get in touch โ† Back to workshops