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:
| Environment | Who sees it | Deployed when |
|---|---|---|
| Local (dev) | Just you | Always โ it's your machine (Parts 1โ6) |
| Preview | Your team, per PR | Automatically on every pull request |
| Staging | Your team | On merge to main (optional) |
| Production | Everyone | On 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.
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.
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:
| Layer | Tests | Speed & count | Tools |
|---|---|---|---|
| Unit | One function/component in isolation โ logic, edge cases | Milliseconds ยท many | Vitest / Jest ยท pytest |
| Integration | Pieces together โ a route + its real database, a repository | Seconds ยท some | Supertest ยท pytest + test DB |
| End-to-end | The whole app as a user โ click, type, assert the screen | Slow ยท few | Playwright / 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 }}" }
- Push logic down the pyramid โ a bug caught by a millisecond unit test is far cheaper than one caught by a 30-second browser test.
- Integration tests need real boundaries โ run a throwaway Postgres as a GitHub Actions service container (or
sam localfor Part 5), not a mock. - Keep e2e few and critical โ cover the flows that must never break (sign in, checkout), not every corner; they're slow and the most brittle.
- Gate the merge on tests โ add the test jobs to branch protection alongside
build, so red tests block the PR.
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.
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.
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.
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:
| Kind | Examples | Where it lives |
|---|---|---|
| Deploy-time | Cloud access, VERCEL_TOKEN, registry login | GitHub โ Actions secrets (prefer OIDC over stored keys) |
| Run-time | Database URL, API keys the app uses | Vercel 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 }}" }
- Secrets vs. variables โ sensitive values go in Secrets (masked, write-only). Non-sensitive config (a region, a public URL) goes in Variables, read with
${{ vars.NAME }}. - Scope with environments โ put production credentials in the
productionenvironment's secrets, so only approved deploy jobs can read them. - Prefer OIDC to long-lived keys โ a short-lived, role-scoped token beats an
AWS_SECRET_ACCESS_KEYsitting in storage forever (see Step 3). - Least-privilege & rotation โ grant each credential only what it needs, and rotate any static token on a schedule (and immediately if it leaks).
- Runtime secrets aren't build args โ never bake them into a Docker image or commit a
.env. Inject them at run time from SSM / Secrets Manager or the platform's env settings.
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 sync --deleteuploads changed files and prunes ones you removed, so the bucket mirrors your build exactly.- Always invalidate CloudFront โ without it the CDN keeps serving the old cached files and your deploy looks like it "did nothing".
- Cache smartly: hashed assets (
app-abc123.js) can cache forever; setindex.htmlto no-cache so new deploys show immediately. - Keep the bucket private โ don't make it public. Serve it through CloudFront with Origin Access Control (OAC), so S3 only answers the CDN.
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.
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
- Tag by commit SHA, immutably โ
notes-api:<sha>names the exact artifact. Turn on ECR tag immutability so a tag can never be overwritten โ that's what makes rollback and promotion trustworthy. A moving:latest/:devtag is a convenience pointer, never the deploy target. - Cache layers โ
cache-from/cache-to: type=ghareuses unchanged layers across runs, cutting build minutes sharply. Multi-stage Dockerfile (Part 6) + caching = small, fast images. - Scan on push โ enable ECR scan-on-push (or Trivy in CI) so a known-vulnerable base image fails the build, not production.
- Prune with a lifecycle policy โ an ECR rule ("keep last 20 images; expire untagged after 7 days") stops the registry โ and the bill โ growing forever.
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 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.
| Trigger | Environment(s) | Gate |
|---|---|---|
| Open / update a PR | Dev ticket env โ one isolated, disposable stack per ticket | CI green; torn down on close |
Merge to main | Dev Integration + Staging | Automatic โ both deploy on every merge |
Publish a Release (vX.Y.Z) | Production | Required 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.
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 }} --forceName 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)
- Namespace everything by the ticket โ the SAM stack name, or the ECS service + target group + log group + DB schema, all keyed on the ticket id (
jnet-1234, orpr-<n>as a fallback). Unique names mean no collisions and trivial teardown. - Isolate data cheaply โ a schema per ticket in a shared dev database (created on up, dropped on down). Never point a ticket env at production data.
- Always tear down โ the
closedtrigger runssam delete/delete-service. Add a nightly sweep for orphans and a TTL so a stale env can't linger and bill. - Post the URL on the PR โ comment the env's URL so reviewers and the e2e job get one click to the live env.
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.
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- Both at once, automatically โ a matrix over
[dev-integration, staging]deploys the merged commit to both with no gate. Integration and full e2e suites run here, against real infra. - Staging mirrors production โ same sizes, same config shape, its own data. It catches what a dev-sized, isolated ticket env can't.
- Build once โ the container image is built a single time and both stages deploy that SHA; SAM builds the same bundle and deploys per
--config-env. - Migrations run first โ per stage, before the deploy, and backward-compatible.
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- Releases trigger prod, merges don't โ
on: release: [published]means production ships only when you deliberately cutvX.Y.Z, decoupled from the constant flow into staging. - Deploy the tagged commit โ check out
github.event.release.tag_name(container: pull the image tagged with that commit's SHA) so prod runs exactly what the release points to. - Approval is the gate โ the
productionenvironment's required reviewer pauses the job until a human approves; its secrets are readable only by this job. - Rollback = re-release โ publish (or re-point to) the previous good tag and prod redeploys that artifact. Deterministic, because every release is immutable.
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.
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
| Workflow | Trigger | What it does |
|---|---|---|
CI / Ticket Deployci.yml โ api-ci | PR to main | Lint + unit/integration tests, deploy the PR's ticket env, run Playwright e2e against it. |
Release Integrationapi-main.yml โ api-release | push to main / release/* | The integration pipeline โ provision infra โ migrate DB โ deploy โ map API โ e2e. |
Infra Provisioninginfrastructure.yml | manual (env, plan/apply) | Terragrunt plan/apply. Also runs as a job inside the Release Integration & Production pipelines. |
DB Migrationsdb-migrations.yml | manual + in-pipeline | Alembic migrations for the target stage. |
API Mappingsapi-mappings.yml | manual + in-pipeline | Registers the service's base-path mapping on the shared API Gateway custom domain. |
Playwright E2Eplaywright.yml | manual (dev/cloud/tech) | Re-run e2e against a live env without redeploying (PR e2e already runs in CI). |
Create Releaserelease-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 Envremove-env.yml | manual (ticket) + sweep | Tears 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
- Infra provisioning โ Terragrunt/Terraform plans and applies the service's infrastructure on top of the shared platform (Part 2). It runs inside the pipeline and is also a manual
plan/applyentrypoint per environment. - DB migrations โ Alembic applies pending schema changes for the target stage before the deploy, so new code never meets an old schema. Migrations are backward-compatible so a rollback stays safe.
- API mappings โ registers the service's base-path mapping on the shared API Gateway custom domain, so the API is reachable at a stable host per stage (e.g.
โฆapi.junctionnet.dev/<service>) instead of a random API Gateway URL. - E2E (Playwright) โ on a PR it runs against the freshly-deployed ticket env (gated in CI); the standalone workflow re-runs it against a live env (dev/cloud/tech) on demand.
- Release creation โ a manual Create Release picks a
patch/minor/majorbump, computes the next semver, and pushes thevX.Y.Ztag. - Production deploy & approval โ pushing that
v*tag triggers the production workflow, which targets theproductionGitHub Environment. A required reviewer on that environment must approve before the job runs โ production ships only on a tag and a human's click.
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
- Semantic versioning โ
vMAJOR.MINOR.PATCH: breaking, feature, fix. It tells everyone what a bump means. - Automate the bump + changelog โ tools like
release-pleaseorchangesetsread your commits and open a release PR for you. - One artifact, many environments โ promote the same build from preview to prod; don't rebuild and hope it matches.
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.
- Separate CI from CD โ one workflow that always runs checks, another that only deploys on the right trigger.
- Gate production โ name the environment and the approval so nothing ships unreviewed.
- OIDC, not keys โ ask for keyless auth explicitly; it's the modern default.
- Build once, deploy that โ the same artifact from preview to prod, tagged by commit.
- Verify step โ insist on a plan/diff before the deploy so you approve reality, not intent.
This automates every path in the series: the Part 1 frontend on Vercel (Part 3), the Node.js API (Part 4), the Python + SAM API (Part 5), and the ECS Fargate container (Part 6). Drive it all like a pro with Part 8.