Skip to content
Draft
190 changes: 190 additions & 0 deletions .github/actions/build-push-ecr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Build & Push to ECR

Composite action that builds a Docker image and pushes to Amazon ECR. Tags with git SHA (for K8s/ArgoCD) and `latest` (for ECS).

## Features

- BuildKit with GHA cache for fast builds
- Auto-creates ECR repo if it doesn't exist (with scan-on-push)
- Skips build if SHA tag already exists in ECR
- Atomic push (all tags in one operation)
- ClickUp notifications on success and failure
- Input validation (service name, environment, account ID, file paths)
- OCI labels for build traceability
- All third-party actions pinned to SHA

## Usage

### Single service

```yaml
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4

- uses: habuildserver/.github/.github/actions/build-push-ecr@main
id: build
with:
service-name: chat-service
environment: development
aws-account-id: '963127282571'
sdk-token: ${{ secrets.SDK_TOKEN }}

- run: echo "Pushed ${{ steps.build.outputs.image-uri }}"
```

### Mono-repo (multiple services)

```yaml
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4

- uses: habuildserver/.github/.github/actions/build-push-ecr@main
with:
service-name: orchestration-service
environment: development
aws-account-id: '963127282571'
dockerfile: orchestration-service/build/Dockerfile
context: .

- uses: habuildserver/.github/.github/actions/build-push-ecr@main
with:
service-name: provider-service
environment: development
aws-account-id: '963127282571'
dockerfile: provider-service/build/Dockerfile
context: .
```

### Worker from same repo

```yaml
- uses: habuildserver/.github/.github/actions/build-push-ecr@main
with:
service-name: chat-worker
environment: development
aws-account-id: '963127282571'
dockerfile: build/worker/Dockerfile-development
```

### With submodules and ClickUp notifications

```yaml
- uses: habuildserver/.github/.github/actions/build-push-ecr@main
with:
service-name: user-service
environment: development
aws-account-id: '963127282571'
sdk-token: ${{ secrets.SDK_TOKEN }}
common-token: ${{ secrets.COMMON_TOKEN }}
use-submodules: 'true'
clickup-token: ${{ secrets.CLICKUP_TOKEN }}
clickup-workspace-id: ${{ secrets.CLICKUP_WORKSPACE_ID }}
clickup-channel-id: ${{ secrets.CLICKUP_CHANNEL_ID }}
```

### With extra build args

```yaml
- uses: habuildserver/.github/.github/actions/build-push-ecr@main
with:
service-name: chat-service
environment: development
aws-account-id: '963127282571'
extra-build-args: |
NODE_ENV=production
BUILD_DATE=2026-04-17
```

## Inputs

| Input | Required | Default | Description |
|-------|----------|---------|-------------|
| `service-name` | Yes | | ECR repo name (e.g., `chat-service`) |
| `environment` | Yes | `development` | `development`, `staging`, or `production` |
| `aws-account-id` | Yes | | 12-digit AWS account ID |
| `dockerfile` | No | `build/Dockerfile-<env>` | Path to Dockerfile |
| `context` | No | `.` | Docker build context |
| `aws-region` | No | `ap-south-1` | AWS region |
| `use-submodules` | No | `false` | Checkout with recursive submodules |
| `extra-build-args` | No | | Additional build args (one per line, `KEY=value`) |
| `notify-clickup` | No | `true` | Send ClickUp notifications |
| `sdk-token` | No | | GitHub Packages token for private npm |
| `common-token` | No | | PAT for submodule checkout |
| `clickup-token` | No | | ClickUp API token |
| `clickup-workspace-id` | No | | ClickUp workspace ID |
| `clickup-channel-id` | No | | ClickUp channel ID |

## Outputs

| Output | Description | Example |
|--------|-------------|---------|
| `image-tag` | Full git SHA | `abc123def456...` |
| `image-uri` | Full image URI with SHA tag | `963127282571.dkr.ecr.ap-south-1.amazonaws.com/chat-service-development:abc123...` |
| `short-sha` | 7-char SHA | `abc123d` |
| `ecr-repo` | ECR repo URI (without tag) | `963127282571.dkr.ecr.ap-south-1.amazonaws.com/chat-service-development` |

## ECR naming convention

Repos are named `<service-name>-<environment>`:
- `chat-service-development`
- `chat-service-staging`
- `chat-service-production`

## Rollback

**ECS:** Re-run the workflow for the desired commit, or re-tag a previous SHA image as `latest`.

**K8s/ArgoCD:** Revert the gitops manifest to the previous SHA tag. ArgoCD self-heals automatically.

## Service image requirements

The action builds whatever Dockerfile you hand it — it cannot enforce that
the resulting image is actually deployable to K8s. The archetype chart
(`charts/services/web-service/` in the gitops repo) imposes constraints
that your Dockerfile must honour. If it doesn't, the image builds
successfully but crashes at pod start with opaque runtime errors
(OCI mount failures, `EACCES` on scratch writes, etc.).

Baseline constraints:

1. **No env secrets in image layers.** Add a `.dockerignore` excluding
`.env*` (and typically `.git/`, tests, editor configs). `COPY . .`
without a `.dockerignore` will ship dev secrets to every environment.
2. **Runs with a read-only rootfs.** Scratch space must come from an
explicit volume (`/tmp` emptyDir in the lib chart), not from writing
to `/app/` or `/var/`. Test locally with
`docker run --read-only --tmpfs /tmp <image>`.
3. **No build-time migrations or integration tests.** `prisma migrate
deploy` belongs in a K8s `Job` at deploy time, not in a Dockerfile
`RUN`. Tests belong in CI before this action runs.
4. **No `:latest`-only behaviour.** K8s consumers read the SHA tag
(`image-tag` / `image-uri` outputs). `:latest` is retained for
ECS backward compat during migration only.

Full tactical detail — `.dockerignore` template, Dockerfile patches,
verification checklist before enabling CSI file mount — lives in the
gitops repo:

> [`habuild-k8s-gitops/docs/runbooks/csi-file-mount-image-requirements.md`](https://github.com/habuildserver/habuild-k8s-gitops/blob/main/docs/runbooks/csi-file-mount-image-requirements.md)

The strategic context (why these standards exist, enforcement-point
mapping, per-service migration sequencing) is in the infra-plans Phase
4 doc on [service image standards](https://github.com/habuildserver/infra-plans/blob/main/phases/04-full-migration/service-image-standards.html).

## Prerequisites

- AWS OIDC role `github-actions-oidc-role` configured in the target account
- Caller job must have `permissions: { contents: read, id-token: write }`
- `jq` available on runner (pre-installed on `ubuntu-latest`)
189 changes: 189 additions & 0 deletions .github/actions/build-push-ecr/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Composite action — builds a Docker image and pushes to ECR.
# Tags with git SHA (for K8s/ArgoCD) and `latest` (for ECS backwards compat).
# Uses BuildKit with GHA cache for fast, cached builds.
#
# Usage:
#
# - uses: habuildserver/.github/.github/actions/build-push-ecr@main
# with:
# service-name: chat-service
# environment: development
# aws-account-id: '963127282571'
# sdk-token: ${{ secrets.SDK_TOKEN }}
#
# Rollback (ECS): Re-run for the desired commit, or re-tag a previous SHA as latest.
# Rollback (K8s): Revert the gitops manifest to the previous SHA tag.

name: Build & Push to ECR
description: Builds a Docker image, tags with git SHA + latest, pushes to ECR

inputs:
service-name:
description: 'ECR repository name (e.g., chat-service)'
required: true
environment:
description: 'Target environment (development, staging, production)'
required: true
default: 'development'
dockerfile:
description: 'Path to Dockerfile (default: build/Dockerfile-<environment>)'
required: false
context:
description: 'Docker build context (default: .)'
required: false
default: '.'
aws-region:
description: 'AWS region'
required: false
default: 'ap-south-1'
aws-account-id:
description: 'AWS account ID for ECR (differs per environment)'
required: true
use-submodules:
description: 'Checkout with recursive submodules (true/false)'
required: false
default: 'false'
extra-build-args:
description: 'Additional docker build args (one per line, KEY=value format)'
required: false
default: ''
notify-clickup:
description: 'Send deploy notification to ClickUp (true/false)'
required: false
default: 'true'
# Secrets passed as inputs (GitHub still masks them in logs)
sdk-token:
description: 'GitHub Packages token for private npm'
required: false
common-token:
description: 'PAT for submodule checkout'
required: false
clickup-token:
description: 'ClickUp API token'
required: false
clickup-workspace-id:
description: 'ClickUp workspace ID'
required: false
clickup-channel-id:
description: 'ClickUp channel ID for notifications'
required: false

outputs:
image-tag:
description: 'The full git SHA image tag that was pushed'
value: ${{ steps.meta.outputs.image-tag }}
image-uri:
description: 'Full image URI with SHA tag'
value: ${{ steps.meta.outputs.image-uri }}
short-sha:
description: 'Short (7-char) SHA tag'
value: ${{ steps.meta.outputs.short-sha }}
ecr-repo:
description: 'Full ECR repository URI (without tag)'
value: ${{ steps.meta.outputs.ecr-repo }}

runs:
using: composite
steps:
- name: Validate inputs
shell: bash
env:
SERVICE_NAME: ${{ inputs.service-name }}
ENVIRONMENT: ${{ inputs.environment }}
AWS_ACCOUNT_ID: ${{ inputs.aws-account-id }}
DOCKERFILE_PATH: ${{ inputs.dockerfile }}
CONTEXT_PATH: ${{ inputs.context }}
run: ${{ github.action_path }}/scripts/validate.sh

- name: Set image metadata
id: meta
shell: bash
env:
SERVICE_NAME: ${{ inputs.service-name }}
ENVIRONMENT: ${{ inputs.environment }}
AWS_ACCOUNT_ID: ${{ inputs.aws-account-id }}
AWS_REGION: ${{ inputs.aws-region }}
DOCKERFILE_PATH: ${{ inputs.dockerfile }}
run: ${{ github.action_path }}/scripts/set-metadata.sh

- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::${{ inputs.aws-account-id }}:role/github-actions-oidc-role
aws-region: ${{ inputs.aws-region }}

- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1

- name: Ensure ECR repository exists
shell: bash
env:
REPO_NAME: ${{ inputs.service-name }}-${{ inputs.environment }}
SERVICE_NAME: ${{ inputs.service-name }}
ENVIRONMENT: ${{ inputs.environment }}
run: ${{ github.action_path }}/scripts/ensure-ecr-repo.sh

- name: Check if image already exists
id: check-existing
shell: bash
env:
REPO_NAME: ${{ inputs.service-name }}-${{ inputs.environment }}
run: ${{ github.action_path }}/scripts/check-existing-image.sh

- name: Configure npm for private packages
if: steps.check-existing.outputs.exists != 'true' && inputs.sdk-token != ''
shell: bash
run: |
echo "@habuildserver:registry=https://npm.pkg.github.com/" >> ~/.npmrc
echo "//npm.pkg.github.com/:_authToken=${{ inputs.sdk-token }}" >> ~/.npmrc

- name: Set up Docker Buildx
if: steps.check-existing.outputs.exists != 'true'
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0

- name: Build and push
if: steps.check-existing.outputs.exists != 'true'
uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c3bc4c94 # v6.18.0
with:
context: ${{ inputs.context }}
file: ${{ steps.meta.outputs.dockerfile }}
push: true
tags: |
${{ steps.meta.outputs.ecr-repo }}:${{ github.sha }}
${{ steps.meta.outputs.ecr-repo }}:${{ steps.meta.outputs.short-sha }}
${{ steps.meta.outputs.ecr-repo }}:latest
build-args: |
SDK_TOKEN=${{ inputs.sdk-token }}
${{ inputs.extra-build-args }}
cache-from: type=gha
cache-to: type=gha,mode=max
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
com.habuild.build.run-id=${{ github.run_id }}
com.habuild.build.run-url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

- name: Notify ClickUp (success)
if: success() && inputs.notify-clickup == 'true'
shell: bash
env:
CLICKUP_TOKEN: ${{ inputs.clickup-token }}
CLICKUP_WORKSPACE_ID: ${{ inputs.clickup-workspace-id }}
CLICKUP_CHANNEL_ID: ${{ inputs.clickup-channel-id }}
SERVICE_NAME: ${{ inputs.service-name }}
ENVIRONMENT: ${{ inputs.environment }}
SHORT_SHA: ${{ steps.meta.outputs.short-sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: ${{ github.action_path }}/scripts/notify-clickup.sh success

- name: Notify ClickUp (failure)
if: failure() && inputs.notify-clickup == 'true'
shell: bash
env:
CLICKUP_TOKEN: ${{ inputs.clickup-token }}
CLICKUP_WORKSPACE_ID: ${{ inputs.clickup-workspace-id }}
CLICKUP_CHANNEL_ID: ${{ inputs.clickup-channel-id }}
SERVICE_NAME: ${{ inputs.service-name }}
ENVIRONMENT: ${{ inputs.environment }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: ${{ github.action_path }}/scripts/notify-clickup.sh failure
14 changes: 14 additions & 0 deletions .github/actions/build-push-ecr/scripts/check-existing-image.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail

# Checks if an image with the given tag already exists in ECR.
# Expected env vars: REPO_NAME, GITHUB_SHA
# Writes to GITHUB_OUTPUT: exists=true|false

if aws ecr describe-images --repository-name "$REPO_NAME" \
--image-ids imageTag="${GITHUB_SHA}" 2>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Image ${GITHUB_SHA} already exists in ECR — skipping build"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
Loading