From 050aefaead1f722585aa80c9f4b631902f2ffeec Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 6 Oct 2026 14:07:46 -0400 Subject: [PATCH] feat(ci): add docker build and ECR image publish reusables Image repos need a registry-free build check and an ECR publish that stops at a mutable tag, without an ECS or Lambda update. --- .github/workflows/cd-ecr-image.yaml | 319 ++++++++++++++++++++++++++++ .github/workflows/ci-docker.yaml | 114 ++++++++++ README.md | 4 + 3 files changed, 437 insertions(+) create mode 100644 .github/workflows/cd-ecr-image.yaml create mode 100644 .github/workflows/ci-docker.yaml diff --git a/.github/workflows/cd-ecr-image.yaml b/.github/workflows/cd-ecr-image.yaml new file mode 100644 index 0000000..89b29af --- /dev/null +++ b/.github/workflows/cd-ecr-image.yaml @@ -0,0 +1,319 @@ +name: CD — ECR image + +# Reusable ECR image publish. The caller owns triggers and passes +# environment as a with: input. This job owns environment:, concurrency, +# OIDC, and vars.DEPLOY_ROLE_ARN. GitHub rejects environment: beside uses:. +# +# Publishes sha-, optionally smokes that image, then moves +# promote-tag (default current). A failed smoke does not move the promote +# tag. Nothing here updates ECS, Lambda, or an HCP run. +# +# dockerfile is relative to context. After the promote push, DescribeImages +# must show the same digest on promote-tag and sha-. +# +# Caller example: +# jobs: +# publish: +# uses: Sea-Haven-Industries/.github/.github/workflows/cd-ecr-image.yaml@ # vX.Y.Z +# permissions: { contents: read, id-token: write } +# secrets: inherit +# with: +# environment: ci +# image-name: actions-runner +# context: runner +# platform: linux/amd64 +# smoke-script: runner/smoke.sh +# ship-gate: true + +on: + workflow_call: + inputs: + environment: + description: "GitHub Environment whose DEPLOY_ROLE_ARN publishes the image" + type: string + required: true + ref: + description: "Git ref to build. Empty means github.sha." + type: string + required: false + default: "" + image-name: + description: "ECR repository name" + type: string + required: true + context: + description: "Docker build context, relative to the repo root" + type: string + required: false + default: "." + dockerfile: + description: "Dockerfile path relative to context" + type: string + required: false + default: "Dockerfile" + platform: + description: "docker build --platform value" + type: string + required: false + default: "linux/amd64" + promote-tag: + description: "Mutable tag moved after a successful smoke" + type: string + required: false + default: "current" + smoke-script: + description: "Checkout path to run inside the image with bash before promotion. Empty skips the smoke." + type: string + required: false + default: "" + ship-gate: + description: "Require the ref to be on main or a legal hotfix/release tag" + type: boolean + required: false + default: false + +permissions: + contents: read + id-token: write + +jobs: + deploy: + name: Publish ${{ inputs.image-name }} to ${{ inputs.environment }} + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: ${{ inputs.environment }} + concurrency: + group: deploy-${{ inputs.image-name }}-${{ inputs.environment }} + cancel-in-progress: false + env: + AWS_REGION: us-east-1 + DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.sha }} + persist-credentials: false + fetch-tags: true + + - name: Resolve commit + id: commit + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + echo "Building ${sha}" + + - name: Ship-gate + if: ${{ inputs.ship-gate }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + INPUT_REF: ${{ inputs.ref != '' && inputs.ref || github.sha }} + ENVIRONMENT: ${{ inputs.environment }} + HEAD_SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + + status="$(gh api "repos/${REPO}/compare/main...${INPUT_REF}" --jq .status)" + if [ "${status}" = "behind" ] || [ "${status}" = "identical" ]; then + echo "ship-gate: ${INPUT_REF} is ${status} relative to main" + exit 0 + fi + + echo "ship-gate: ${INPUT_REF} is not on main (compare status: ${status}); checking hotfix/release path" + + TAG="${INPUT_REF}" + if [[ ! "${TAG}" =~ ^v ]]; then + TAG="$(git describe --tags --exact-match "${HEAD_SHA}" 2>/dev/null || true)" + fi + + if [ "${ENVIRONMENT}" = "staging" ]; then + PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-staging$' + else + PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$' + fi + + if [[ -z "${TAG}" ]] || [[ ! "${TAG}" =~ ${PATTERN} ]]; then + echo "ship-gate: ref ${INPUT_REF} (tag ${TAG:-none}) does not match ${PATTERN} for environment ${ENVIRONMENT} and is not on main" >&2 + exit 1 + fi + + export PATTERN TAG + PREV="$( + gh api "repos/${REPO}/releases" --paginate --jq '.[].tag_name' | python3 -c 'import os, re, sys + pattern = re.compile(os.environ["PATTERN"]) + current = os.environ["TAG"] + tags = [ + line.strip() + for line in sys.stdin + if pattern.fullmatch(line.strip()) and line.strip() != current + ] + def key(tag): + body = tag[1:] + core = body.split("-", 1)[0] + return tuple(int(part) for part in core.split(".")) + tags.sort(key=key) + print(tags[-1] if tags else "")' + )" + + if [ -z "${PREV}" ]; then + echo "ship-gate: no previous matching release tag; hotfix path requires a prior ${PATTERN} release" >&2 + exit 1 + fi + + ff_status="$(gh api "repos/${REPO}/compare/${PREV}...${TAG}" --jq .status)" + if [ "${ff_status}" != "ahead" ]; then + echo "ship-gate: ${TAG} is not a fast-forward of ${PREV} (status: ${ff_status})" >&2 + exit 1 + fi + + from_train=false + TARGET_COMMITISH="$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq .target_commitish 2>/dev/null || true)" + if [[ "${TARGET_COMMITISH}" == hotfix/* || "${TARGET_COMMITISH}" == release/* ]]; then + from_train=true + fi + + if [ "${from_train}" = false ]; then + git fetch origin '+refs/heads/hotfix/*:refs/remotes/origin/hotfix/*' '+refs/heads/release/*:refs/remotes/origin/release/*' || true + if git branch -r --contains "${HEAD_SHA}" | grep -Eq 'origin/(hotfix|release)/'; then + from_train=true + fi + fi + + if [ "${from_train}" = false ]; then + echo "ship-gate: ${TAG} was not created from hotfix/* or release/* (target_commitish=${TARGET_COMMITISH:-none})" >&2 + exit 1 + fi + + echo "ship-gate: hotfix/release path accepted (${PREV} -> ${TAG} from ${TARGET_COMMITISH:-branch})" + + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Login to Amazon ECR + id: login + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7 + + - name: Build and push the commit tag + id: build + env: + REGISTRY: ${{ steps.login.outputs.registry }} + IMAGE_NAME: ${{ inputs.image-name }} + SHA: ${{ steps.commit.outputs.sha }} + CONTEXT: ${{ inputs.context }} + DOCKERFILE: ${{ inputs.dockerfile }} + PLATFORM: ${{ inputs.platform }} + run: | + set -euo pipefail + if [ -z "${REGISTRY}" ]; then + echo "ECR login did not return a registry" >&2 + exit 1 + fi + case "${IMAGE_NAME}" in + ""|*[[:space:]]*|*..*) + echo "image-name must be an ECR repository name" >&2 + exit 1 + ;; + esac + if [[ ! "${SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "resolved commit is not a full SHA" >&2 + exit 1 + fi + case "${CONTEXT}" in + ""|/*|*..*) + echo "context must be a relative path without '..'" >&2 + exit 1 + ;; + esac + case "${DOCKERFILE}" in + ""|/*|*..*) + echo "dockerfile must be a relative path without '..'" >&2 + exit 1 + ;; + esac + if [ -z "${PLATFORM}" ]; then + echo "platform is required" >&2 + exit 1 + fi + dockerfile_path="${CONTEXT%/}/${DOCKERFILE}" + if [ ! -f "${dockerfile_path}" ]; then + echo "dockerfile not found: ${dockerfile_path}" >&2 + exit 1 + fi + image="${REGISTRY}/${IMAGE_NAME}:sha-${SHA}" + docker build --platform "${PLATFORM}" -f "${dockerfile_path}" -t "${image}" "${CONTEXT}" + docker push "${image}" + echo "image=${image}" >> "${GITHUB_OUTPUT}" + + - name: Smoke the image + if: ${{ inputs.smoke-script != '' }} + env: + IMAGE: ${{ steps.build.outputs.image }} + SMOKE_SCRIPT: ${{ inputs.smoke-script }} + run: | + set -euo pipefail + case "${SMOKE_SCRIPT}" in + ""|/*|*..*) + echo "smoke-script must be a relative path without '..'" >&2 + exit 1 + ;; + esac + if [ ! -f "${SMOKE_SCRIPT}" ]; then + echo "smoke-script not found: ${SMOKE_SCRIPT}" >&2 + exit 1 + fi + docker run --rm --entrypoint bash \ + -v "${GITHUB_WORKSPACE}/${SMOKE_SCRIPT}:/tmp/smoke.sh:ro" \ + "${IMAGE}" \ + /tmp/smoke.sh + + - name: Promote tag + env: + IMAGE: ${{ steps.build.outputs.image }} + REGISTRY: ${{ steps.login.outputs.registry }} + IMAGE_NAME: ${{ inputs.image-name }} + PROMOTE_TAG: ${{ inputs.promote-tag }} + run: | + set -euo pipefail + case "${PROMOTE_TAG}" in + ""|*[[:space:]]*|*/*) + echo "promote-tag must be a single image tag" >&2 + exit 1 + ;; + esac + promoted="${REGISTRY}/${IMAGE_NAME}:${PROMOTE_TAG}" + docker tag "${IMAGE}" "${promoted}" + docker push "${promoted}" + + - name: Verify promoted digest + env: + IMAGE_NAME: ${{ inputs.image-name }} + SHA: ${{ steps.commit.outputs.sha }} + PROMOTE_TAG: ${{ inputs.promote-tag }} + run: | + set -euo pipefail + sha_tag="sha-${SHA}" + for _ in 1 2 3 4 5 6; do + sha_digest="$(aws ecr describe-images \ + --repository-name "${IMAGE_NAME}" \ + --image-ids "imageTag=${sha_tag}" \ + --query 'imageDetails[0].imageDigest' \ + --output text)" + promote_digest="$(aws ecr describe-images \ + --repository-name "${IMAGE_NAME}" \ + --image-ids "imageTag=${PROMOTE_TAG}" \ + --query 'imageDetails[0].imageDigest' \ + --output text)" + if [ "${sha_digest}" = "${promote_digest}" ] && [ -n "${sha_digest}" ] && [ "${sha_digest}" != "None" ]; then + echo "promote tag ${PROMOTE_TAG} digest matches ${sha_tag}: ${sha_digest}" + exit 0 + fi + echo "digest mismatch (sha=${sha_digest:-empty} promote=${promote_digest:-empty}); retrying" + sleep 5 + done + echo "promote tag ${PROMOTE_TAG} digest does not match ${sha_tag}" >&2 + exit 1 diff --git a/.github/workflows/ci-docker.yaml b/.github/workflows/ci-docker.yaml new file mode 100644 index 0000000..7cb3bc4 --- /dev/null +++ b/.github/workflows/ci-docker.yaml @@ -0,0 +1,114 @@ +name: CI — Docker + +# Reusable Docker image CI. Builds from the checkout and, when smoke-script +# is set, runs that file inside the image. No registry login and no push. +# The caller owns the ci-complete aggregator. +# +# dockerfile is relative to context. context runner with the default +# Dockerfile builds runner/Dockerfile. +# +# Caller example: +# jobs: +# image: +# uses: Sea-Haven-Industries/.github/.github/workflows/ci-docker.yaml@ # vX.Y.Z +# with: +# context: runner +# smoke-script: runner/smoke.sh +# +# The trailing concurrency segment is the job id written literally, not +# github.job. In a called workflow that expression is the caller's job id. + +on: + workflow_call: + inputs: + context: + description: "Docker build context, relative to the repo root" + type: string + required: false + default: "." + dockerfile: + description: "Dockerfile path relative to context" + type: string + required: false + default: "Dockerfile" + platform: + description: "docker build --platform value" + type: string + required: false + default: "linux/amd64" + smoke-script: + description: "Checkout path to run inside the image with bash. Empty skips the smoke." + type: string + required: false + default: "" + +permissions: + contents: read + +jobs: + docker: + name: docker + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: ci-docker-${{ github.workflow }}-${{ github.ref }}-${{ inputs.context }}-${{ inputs.dockerfile }}-docker + cancel-in-progress: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build image + id: build + env: + CONTEXT: ${{ inputs.context }} + DOCKERFILE: ${{ inputs.dockerfile }} + PLATFORM: ${{ inputs.platform }} + run: | + set -euo pipefail + case "${CONTEXT}" in + ""|/*|*..*) + echo "context must be a relative path without '..'" >&2 + exit 1 + ;; + esac + case "${DOCKERFILE}" in + ""|/*|*..*) + echo "dockerfile must be a relative path without '..'" >&2 + exit 1 + ;; + esac + if [ -z "${PLATFORM}" ]; then + echo "platform is required" >&2 + exit 1 + fi + dockerfile_path="${CONTEXT%/}/${DOCKERFILE}" + if [ ! -f "${dockerfile_path}" ]; then + echo "dockerfile not found: ${dockerfile_path}" >&2 + exit 1 + fi + image="ci-docker:local" + docker build --platform "${PLATFORM}" -f "${dockerfile_path}" -t "${image}" "${CONTEXT}" + echo "image=${image}" >> "${GITHUB_OUTPUT}" + + - name: Smoke the image + if: ${{ inputs.smoke-script != '' }} + env: + IMAGE: ${{ steps.build.outputs.image }} + SMOKE_SCRIPT: ${{ inputs.smoke-script }} + run: | + set -euo pipefail + case "${SMOKE_SCRIPT}" in + ""|/*|*..*) + echo "smoke-script must be a relative path without '..'" >&2 + exit 1 + ;; + esac + if [ ! -f "${SMOKE_SCRIPT}" ]; then + echo "smoke-script not found: ${SMOKE_SCRIPT}" >&2 + exit 1 + fi + docker run --rm --entrypoint bash \ + -v "${GITHUB_WORKSPACE}/${SMOKE_SCRIPT}:/tmp/smoke.sh:ro" \ + "${IMAGE}" \ + /tmp/smoke.sh diff --git a/README.md b/README.md index 55d2d35..88b277c 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,14 @@ The formatter GitHub App is not on the main-branch bypass list. **`.github/workflows/ci-terraform.yaml`** — Terraform `fmt -check`, `init -backend=false`, and `validate`. Default version `1.16.0`. +**`.github/workflows/ci-docker.yaml`** — Docker image CI with no registry login and no push. `docker build` from `context` (default `.`); `dockerfile` is relative to that context (default `Dockerfile`); `platform` defaults to `linux/amd64`. When `smoke-script` is set, that checkout path is mounted read-only and run inside the image with `bash`. The caller owns `ci-complete`. + **`.github/workflows/ci-autofix.yaml`** — Pull-request-only formatter. Mints a GitHub App token (`AUTOFMT_APP_ID`, `AUTOFMT_APP_PRIVATE_KEY`), runs the requested presets and any optional write commands, and pushes `style: apply formatter` only when the tree is dirty. Presets are `prettier` (`npm run format`), `eslint` (`npx eslint . --fix`, opt-in), `ruff` (`ruff format .` and `ruff check --fix .`), and `terraform` (`terraform fmt -recursive` in `terraform-working-directory`, default `terraform`). Output `committed` lets the caller skip portions on SHA_old. Does not `--no-verify` and does not push to `main`. **`.github/workflows/cd-hcp-fargate.yaml`** — HCP Fargate image CD. Checkout at `ref` (empty means `github.sha`), OIDC, SSM cluster/service/family/ecr/container/api-url, docker build+push tagged `$sha` and `$environment`, patch `GIT_SHA`, RegisterTaskDefinition + UpdateService + services-stable, poll health SHA. `environment` is a `with:` input. The reusable job owns `environment:`, concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`. +**`.github/workflows/cd-ecr-image.yaml`** — ECR image publish with no ECS, Lambda, or HCP update. Checkout at `ref` (empty means `github.sha`), optional `ship-gate`, OIDC via `vars.DEPLOY_ROLE_ARN`, `docker build` and push `sha-`, optional `smoke-script` inside that image, then retag and push `promote-tag` (default `current`). A failed smoke does not move the promote tag. `ecr:DescribeImages` must show the same digest on both tags. `environment` is a `with:` input. The reusable job owns `environment:`, concurrency `deploy--`, and OIDC. + **`.github/workflows/cd-hcp-lambda.yaml`** — HCP Lambda zip CD for Node packagers. Checkout at `ref`, optional `ship-gate`, Node 24, `scripts/package_lambdas.mjs --git-sha --out-dir --only `, verifies `src/buildInfo.js` carries the SHA, uploads `functions//.zip` to the SSM `artifacts-bucket`, `update-function-code` on each `-function-name`, waits for `function-updated-v2`. Terraform owns the functions and ignores code attributes. **`.github/workflows/cd-hcp-lambda-python.yaml`** — HCP Lambda zip CD for Python packagers. Same contract as `cd-hcp-lambda.yaml` with `python-version` (default `3.12`), `scripts/package_lambdas.sh --git-sha --out-dir --only `, and `build_info.py` as the SHA marker. After each `update-function-code` settles, `CodeSha256` must equal the base64 SHA-256 of the local zip and `LastUpdateStatus` must be `Successful`. That is the live-state check for functions with no health URL.