diff --git a/.github/actions/build-push-ecr/README.md b/.github/actions/build-push-ecr/README.md new file mode 100644 index 0000000..b2cd5a7 --- /dev/null +++ b/.github/actions/build-push-ecr/README.md @@ -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-` | 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 `-`: +- `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 `. +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`) diff --git a/.github/actions/build-push-ecr/action.yml b/.github/actions/build-push-ecr/action.yml new file mode 100644 index 0000000..28ae504 --- /dev/null +++ b/.github/actions/build-push-ecr/action.yml @@ -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-)' + 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 diff --git a/.github/actions/build-push-ecr/scripts/check-existing-image.sh b/.github/actions/build-push-ecr/scripts/check-existing-image.sh new file mode 100755 index 0000000..7bc3be0 --- /dev/null +++ b/.github/actions/build-push-ecr/scripts/check-existing-image.sh @@ -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 diff --git a/.github/actions/build-push-ecr/scripts/ensure-ecr-repo.sh b/.github/actions/build-push-ecr/scripts/ensure-ecr-repo.sh new file mode 100755 index 0000000..736cb43 --- /dev/null +++ b/.github/actions/build-push-ecr/scripts/ensure-ecr-repo.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Creates the ECR repository if it doesn't exist. +# Expected env vars: REPO_NAME, SERVICE_NAME, ENVIRONMENT + +aws ecr describe-repositories --repository-names "$REPO_NAME" 2>/dev/null && exit 0 + +echo "ECR repo '$REPO_NAME' not found — creating..." +aws ecr create-repository \ + --repository-name "$REPO_NAME" \ + --image-scanning-configuration scanOnPush=true \ + --encryption-configuration encryptionType=AES256 \ + --tags Key=Service,Value="${SERVICE_NAME}" Key=Environment,Value="${ENVIRONMENT}" + +echo "Created ECR repo: $REPO_NAME" diff --git a/.github/actions/build-push-ecr/scripts/notify-clickup.sh b/.github/actions/build-push-ecr/scripts/notify-clickup.sh new file mode 100755 index 0000000..ae73aea --- /dev/null +++ b/.github/actions/build-push-ecr/scripts/notify-clickup.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Sends a ClickUp notification for build success or failure. +# Expected env vars: CLICKUP_TOKEN, CLICKUP_WORKSPACE_ID, CLICKUP_CHANNEL_ID, +# SERVICE_NAME, ENVIRONMENT, GITHUB_REF_NAME, GITHUB_ACTOR, RUN_URL +# Arg: $1 = "success" or "failure" +# Optional env: SHORT_SHA (only for success) + +[ -z "${CLICKUP_TOKEN:-}" ] && exit 0 + +STATUS="${1:-success}" + +if [ "$STATUS" = "success" ]; then + BODY=$(jq -n \ + --arg svc "$SERVICE_NAME" \ + --arg env "$ENVIRONMENT" \ + --arg sha "${SHORT_SHA:-}" \ + --arg branch "$GITHUB_REF_NAME" \ + --arg actor "$GITHUB_ACTOR" \ + --arg url "$RUN_URL" \ + '{type: "message", content_format: "text/md", + content: "Built and pushed **\($svc)** (\($env))\nImage tags: `\($sha)`, `latest`\nBranch: **\($branch)**\nTriggered by: **\($actor)**\n[View run](\($url))"}') +else + BODY=$(jq -n \ + --arg svc "$SERVICE_NAME" \ + --arg env "$ENVIRONMENT" \ + --arg branch "$GITHUB_REF_NAME" \ + --arg actor "$GITHUB_ACTOR" \ + --arg url "$RUN_URL" \ + '{type: "message", content_format: "text/md", + content: "FAILED build **\($svc)** (\($env))\nBranch: **\($branch)**\nTriggered by: **\($actor)**\n[View run](\($url))"}') +fi + +curl -sf -X POST \ + "https://api.clickup.com/api/v3/workspaces/${CLICKUP_WORKSPACE_ID}/chat/channels/${CLICKUP_CHANNEL_ID}/messages" \ + -H "Authorization: ${CLICKUP_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$BODY" || echo "::warning::ClickUp ${STATUS} notification failed" diff --git a/.github/actions/build-push-ecr/scripts/set-metadata.sh b/.github/actions/build-push-ecr/scripts/set-metadata.sh new file mode 100755 index 0000000..f527db1 --- /dev/null +++ b/.github/actions/build-push-ecr/scripts/set-metadata.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Computes image metadata and writes to GITHUB_OUTPUT. +# Expected env vars: AWS_ACCOUNT_ID, AWS_REGION, SERVICE_NAME, ENVIRONMENT, +# DOCKERFILE_PATH, GITHUB_SHA + +ECR_REPO="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${SERVICE_NAME}-${ENVIRONMENT}" +DOCKERFILE="${DOCKERFILE_PATH:-build/Dockerfile-${ENVIRONMENT}}" + +echo "ecr-repo=${ECR_REPO}" >> "$GITHUB_OUTPUT" +echo "image-tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" +echo "short-sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" +echo "image-uri=${ECR_REPO}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" +echo "dockerfile=${DOCKERFILE}" >> "$GITHUB_OUTPUT" + +echo "ECR repo: ${ECR_REPO}" +echo "Tag: ${GITHUB_SHA:0:7} (${GITHUB_SHA})" +echo "Dockerfile: ${DOCKERFILE}" diff --git a/.github/actions/build-push-ecr/scripts/validate.sh b/.github/actions/build-push-ecr/scripts/validate.sh new file mode 100755 index 0000000..5ee855d --- /dev/null +++ b/.github/actions/build-push-ecr/scripts/validate.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validates inputs for the build-push-ecr action. +# Expected env vars: SERVICE_NAME, ENVIRONMENT, AWS_ACCOUNT_ID, DOCKERFILE_PATH, CONTEXT_PATH + +if [[ ! "$SERVICE_NAME" =~ ^[a-z][a-z0-9-]+$ ]]; then + echo "::error::Invalid service-name: must match ^[a-z][a-z0-9-]+$" + exit 1 +fi + +if [[ ! "$ENVIRONMENT" =~ ^(development|staging|production)$ ]]; then + echo "::error::Invalid environment: must be development, staging, or production" + exit 1 +fi + +if [[ ! "$AWS_ACCOUNT_ID" =~ ^[0-9]{12}$ ]]; then + echo "::error::Invalid aws-account-id: must be exactly 12 digits" + exit 1 +fi + +if [ -n "${DOCKERFILE_PATH:-}" ] && [[ ! "$DOCKERFILE_PATH" =~ ^[a-zA-Z0-9_./-]+$ ]]; then + echo "::error::Invalid dockerfile path" + exit 1 +fi + +if [[ ! "${CONTEXT_PATH:-.}" =~ ^[a-zA-Z0-9_./-]+$ ]]; then + echo "::error::Invalid context path" + exit 1 +fi + +echo "All inputs valid"