From 9e3d0d4430bdf74bbe1fdf60a22d473111ab5923 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 14:53:02 -0400 Subject: [PATCH] feat(ci): add HCP reusable workflows and drop Mergify Callers can pin org Fargate/SPA CD and parallel CI instead of copying per-repo deploy jobs. --- .github/workflows/cd-hcp-fargate.yaml | 318 ++++++++++++++++++ .github/workflows/cd-hcp-spa.yaml | 303 +++++++++++++++++ .github/workflows/ci-autofix.yaml | 181 ++++++++++ .github/workflows/ci-frontend.yaml | 262 +++++++++++++++ .github/workflows/ci-terraform.yaml | 57 ++++ .github/workflows/ci-typescript-frontend.yaml | 10 +- .mergify.yml | 44 --- README.md | 141 +++++++- workflow-templates/ci-hcp.properties.json | 7 + workflow-templates/ci-hcp.yml | 53 +++ .../ci-terraform.properties.json | 7 + workflow-templates/ci-terraform.yml | 16 + .../ci-typescript-frontend.properties.json | 2 +- .../hcp-fargate-deploy.properties.json | 7 + workflow-templates/hcp-fargate-deploy.yml | 54 +++ .../hcp-spa-deploy.properties.json | 7 + workflow-templates/hcp-spa-deploy.yml | 51 +++ 17 files changed, 1465 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/cd-hcp-fargate.yaml create mode 100644 .github/workflows/cd-hcp-spa.yaml create mode 100644 .github/workflows/ci-autofix.yaml create mode 100644 .github/workflows/ci-frontend.yaml create mode 100644 .github/workflows/ci-terraform.yaml delete mode 100644 .mergify.yml create mode 100644 workflow-templates/ci-hcp.properties.json create mode 100644 workflow-templates/ci-hcp.yml create mode 100644 workflow-templates/ci-terraform.properties.json create mode 100644 workflow-templates/ci-terraform.yml create mode 100644 workflow-templates/hcp-fargate-deploy.properties.json create mode 100644 workflow-templates/hcp-fargate-deploy.yml create mode 100644 workflow-templates/hcp-spa-deploy.properties.json create mode 100644 workflow-templates/hcp-spa-deploy.yml diff --git a/.github/workflows/cd-hcp-fargate.yaml b/.github/workflows/cd-hcp-fargate.yaml new file mode 100644 index 0000000..c62694b --- /dev/null +++ b/.github/workflows/cd-hcp-fargate.yaml @@ -0,0 +1,318 @@ +name: CD — HCP Fargate + +# Reusable Fargate image CD for HCP app repos. 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:`. +# +# Caller example (one job per GitHub Environment): +# jobs: +# deploy-prod: +# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@ # vX.Y.Z +# permissions: { contents: read, id-token: write } +# secrets: inherit +# with: +# environment: prod +# ref: ${{ github.event.release.tag_name || inputs.ref }} +# ssm-prefix: /meal-order-manager/deploy +# docker-platform: linux/amd64 +# ship-gate: true +# +# Nothing here creates an HCP run. Terraform owns the cluster, service, ALB, +# and ignores container_definitions / task_definition. + +on: + workflow_call: + inputs: + environment: + description: "GitHub Environment to deploy to (dev, staging, prod)" + type: string + required: true + ref: + description: "Git ref to build. Empty means github.sha." + type: string + required: false + default: "" + ssm-prefix: + description: "SSM prefix for deploy parameters (e.g. /meal-order-manager/deploy)" + type: string + required: true + docker-platform: + description: "docker build --platform value" + type: string + required: false + default: "linux/amd64" + health-path: + description: "Health endpoint path appended to SSM api-url" + type: string + required: false + default: "/api/health" + ship-gate: + description: "Require the ref to be on main or a legal hotfix/release tag" + type: boolean + required: false + default: false + extra-task-env: + description: "JSON object of extra container environment keys to merge (e.g. {\"SENTRY_DSN_PARAM\":\"/app/sentry-dsn\"})" + type: string + required: false + default: "{}" + +permissions: + contents: read + id-token: write + +jobs: + deploy: + name: Deploy Fargate to ${{ inputs.environment }} + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ${{ inputs.environment }} + concurrency: + group: deploy-${{ inputs.ssm-prefix }}-${{ 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@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Get deploy parameters + id: deploy + env: + SSM_PREFIX: ${{ inputs.ssm-prefix }} + run: | + set -euo pipefail + get_param() { + aws ssm get-parameter --name "$1" --query Parameter.Value --output text + } + prefix="${SSM_PREFIX%/}" + CLUSTER=$(get_param "${prefix}/cluster") + SERVICE=$(get_param "${prefix}/service") + FAMILY=$(get_param "${prefix}/task-family") + ECR=$(get_param "${prefix}/ecr-repository") + CONTAINER=$(get_param "${prefix}/container-name") + API_URL=$(get_param "${prefix}/api-url") + { + echo "cluster=${CLUSTER}" + echo "service=${SERVICE}" + echo "family=${FAMILY}" + echo "ecr=${ECR}" + echo "container=${CONTAINER}" + echo "api_url=${API_URL}" + } >> "${GITHUB_OUTPUT}" + + - name: Set up QEMU + uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7 + + - name: Build and push image + env: + ECR: ${{ steps.deploy.outputs.ecr }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + ENVIRONMENT: ${{ inputs.environment }} + DOCKER_PLATFORM: ${{ inputs.docker-platform }} + run: | + set -euo pipefail + docker buildx build \ + --platform "${DOCKER_PLATFORM}" \ + --build-arg "GIT_SHA=${GIT_SHA}" \ + -t "${ECR}:${GIT_SHA}" \ + -t "${ECR}:${ENVIRONMENT}" \ + --push \ + . + + - name: Register task definition and update service + env: + CLUSTER: ${{ steps.deploy.outputs.cluster }} + SERVICE: ${{ steps.deploy.outputs.service }} + FAMILY: ${{ steps.deploy.outputs.family }} + CONTAINER: ${{ steps.deploy.outputs.container }} + IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + EXTRA_TASK_ENV: ${{ inputs.extra-task-env }} + run: | + set -euo pipefail + aws ecs describe-task-definition \ + --task-definition "${FAMILY}" \ + --query taskDefinition \ + --output json \ + | python3 -c ' + import json, os, sys + td = json.load(sys.stdin) + for key in ( + "taskDefinitionArn", + "revision", + "status", + "requiresAttributes", + "compatibilities", + "registeredAt", + "registeredBy", + "deregisteredAt", + ): + td.pop(key, None) + image = os.environ["IMAGE"] + sha = os.environ["GIT_SHA"] + name = os.environ["CONTAINER"] + extra_raw = os.environ.get("EXTRA_TASK_ENV") or "{}" + extra_env = json.loads(extra_raw) + if not isinstance(extra_env, dict): + sys.exit("extra-task-env must be a JSON object") + found = False + for container in td["containerDefinitions"]: + if container["name"] != name: + continue + found = True + container["image"] = image + env = {item["name"]: item["value"] for item in container.get("environment", [])} + env["GIT_SHA"] = sha + for key, value in extra_env.items(): + env[str(key)] = str(value) + container["environment"] = [{"name": key, "value": value} for key, value in env.items()] + container.pop("command", None) + if not found: + sys.exit(f"container {name} not in task definition") + json.dump(td, sys.stdout) + ' > /tmp/task-def.json + REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)" + aws ecs update-service \ + --cluster "${CLUSTER}" \ + --service "${SERVICE}" \ + --task-definition "${FAMILY}:${REV}" \ + --force-new-deployment \ + >/dev/null + aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}" + + - name: Verify health SHA + env: + API_URL: ${{ steps.deploy.outputs.api_url }} + HEALTH_PATH: ${{ inputs.health-path }} + EXPECTED_SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + path="${HEALTH_PATH}" + case "${path}" in + /*) ;; + *) path="/${path}" ;; + esac + url="${API_URL%/}${path}" + for _ in 1 2 3 4 5 6; do + BODY="$(curl -fsS "${url}" || true)" + echo "${BODY}" + if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then + exit 0 + fi + sleep 10 + done + echo "health SHA did not match ${EXPECTED_SHA}" >&2 + exit 1 diff --git a/.github/workflows/cd-hcp-spa.yaml b/.github/workflows/cd-hcp-spa.yaml new file mode 100644 index 0000000..fe2485e --- /dev/null +++ b/.github/workflows/cd-hcp-spa.yaml @@ -0,0 +1,303 @@ +name: CD — HCP SPA + +# Reusable CloudFront/S3 SPA CD for HCP app repos. 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:`. +# +# Build env comes from the GitHub Environment: every `vars.VITE_*` value plus +# `secrets.SENTRY_AUTH_TOKEN`. Pass `required-vite-vars` for keys that must +# be set before `npm run build`. +# +# Caller example (one job per GitHub Environment): +# jobs: +# deploy-prod: +# uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-spa.yaml@ # vX.Y.Z +# permissions: { contents: read, id-token: write } +# secrets: inherit +# with: +# environment: prod +# ref: ${{ github.event.release.tag_name || inputs.ref }} +# ssm-prefix: /internal-portal/deploy +# ship-gate: true +# +# Nothing here creates an HCP run. Terraform owns the bucket and distribution. + +on: + workflow_call: + inputs: + environment: + description: "GitHub Environment to deploy to (dev, staging, prod)" + type: string + required: true + ref: + description: "Git ref to build. Empty means github.sha." + type: string + required: false + default: "" + ssm-prefix: + description: "SSM prefix for deploy parameters (e.g. /internal-portal/deploy)" + type: string + required: true + ship-gate: + description: "Require the ref to be on main or a legal hotfix/release tag" + type: boolean + required: false + default: false + required-vite-vars: + description: "Comma-separated VITE_* GitHub Environment variable names that must be set" + type: string + required: false + default: "" + +permissions: + contents: read + id-token: write + +jobs: + deploy: + name: Deploy SPA to ${{ inputs.environment }} + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: ${{ inputs.environment }} + concurrency: + group: deploy-${{ inputs.ssm-prefix }}-${{ 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})" + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + + - name: Build SPA + env: + VARS_JSON: ${{ toJSON(vars) }} + REQUIRED_VITE_VARS: ${{ inputs.required-vite-vars }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + TARGET_ENVIRONMENT: ${{ inputs.environment }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + python3 -c ' + import json, os, shlex, sys + required = [s.strip() for s in os.environ.get("REQUIRED_VITE_VARS", "").split(",") if s.strip()] + vars_obj = json.loads(os.environ["VARS_JSON"]) + missing = [key for key in required if not vars_obj.get(key)] + if missing: + print("Missing required GitHub Environment vars: " + ", ".join(missing), file=sys.stderr) + sys.exit(1) + with open("/tmp/vite.env", "w", encoding="utf-8") as fh: + for key, value in vars_obj.items(): + if key.startswith("VITE_") and value: + fh.write(f"export {key}={shlex.quote(str(value))}\n") + ' + # shellcheck source=/dev/null + source /tmp/vite.env + export VITE_SENTRY_ENVIRONMENT="${TARGET_ENVIRONMENT}" + export VITE_SENTRY_RELEASE="${GIT_SHA}" + npm ci + npm run build + test -f dist/index.html + find dist -name '*.map' -delete + if find dist -name '*.map' | grep -q .; then + echo "SPA source maps must not ship in dist/" >&2 + exit 1 + fi + index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')" + echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}" + echo "dist/index.html sha256=${index_sha}" + + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Get deploy parameters + id: deploy + env: + SSM_PREFIX: ${{ inputs.ssm-prefix }} + run: | + set -euo pipefail + prefix="${SSM_PREFIX%/}" + BUCKET=$(aws ssm get-parameter --name "${prefix}/bucket" --query Parameter.Value --output text) + DIST_ID=$(aws ssm get-parameter --name "${prefix}/distribution-id" --query Parameter.Value --output text) + DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text) + origin_paths="$(aws cloudfront get-distribution --id "${DIST_ID}" \ + --query 'Distribution.DistributionConfig.Origins.Items[].OriginPath' --output text | tr -d '[:space:]')" + if [ -n "${origin_paths}" ]; then + echo "Distribution ${DIST_ID} still has a non-empty origin path (${origin_paths})." >&2 + echo "Wait for the HCP apply that moves the origin to the bucket root, then re-run." >&2 + exit 1 + fi + { + echo "bucket=${BUCKET}" + echo "distribution_id=${DIST_ID}" + echo "site_url=https://${DOMAIN}" + } >> "${GITHUB_OUTPUT}" + + - name: Sync dist/ to the bucket root + env: + SITE_BUCKET: ${{ steps.deploy.outputs.bucket }} + run: | + set -euo pipefail + aws s3 sync dist/ "s3://${SITE_BUCKET}/" \ + --exclude "index.html" \ + --exclude "*.map" \ + --cache-control "public,max-age=31536000,immutable" + aws s3 cp dist/index.html "s3://${SITE_BUCKET}/index.html" \ + --cache-control "no-cache,no-store,must-revalidate" \ + --content-type "text/html" + aws s3 sync dist/ "s3://${SITE_BUCKET}/" \ + --delete \ + --exclude "index.html" \ + --exclude "*.map" \ + --cache-control "public,max-age=31536000,immutable" + aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html + + - name: Invalidate CloudFront + env: + DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }} + run: | + set -euo pipefail + invalidation_id="$(aws cloudfront create-invalidation \ + --distribution-id "${DISTRIBUTION_ID}" \ + --paths "/*" \ + --query Invalidation.Id --output text)" + echo "Invalidation ${invalidation_id} created; waiting" + aws cloudfront wait invalidation-completed \ + --distribution-id "${DISTRIBUTION_ID}" \ + --id "${invalidation_id}" + + - name: Verify served release + env: + DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }} + SITE_URL: ${{ steps.deploy.outputs.site_url }} + EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }} + run: | + set -euo pipefail + SITE_URL="${SITE_URL%/}" + sha256_of() { + python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" + } + last_status="Unknown" + last_hash="Unknown" + for attempt in $(seq 1 40); do + last_status="$(aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --query Distribution.Status --output text)" + if last_hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [ -n "${last_hash}" ]; then + : + else + last_hash="unreachable" + fi + echo "poll ${attempt}/40: status=${last_status} served_sha256=${last_hash}" + if [ "${last_status}" = "Deployed" ] && [ "${last_hash}" = "${EXPECTED_INDEX_SHA256}" ]; then + exit 0 + fi + sleep 15 + done + echo "release did not converge: status=${last_status} served_sha256=${last_hash} expected=${EXPECTED_INDEX_SHA256}" >&2 + exit 1 diff --git a/.github/workflows/ci-autofix.yaml b/.github/workflows/ci-autofix.yaml new file mode 100644 index 0000000..3baae89 --- /dev/null +++ b/.github/workflows/ci-autofix.yaml @@ -0,0 +1,181 @@ +name: CI — Autofix + +# Convenience formatter on pull_request. Keeps format:check / lint in the +# parallel portions as the fail-closed gate. GITHUB_TOKEN commits do not +# retrigger workflows, so this mints a GitHub App token. +# +# Skip forks, merge_group, push, and when the actor is the App (no loop). +# If the tree is dirty, commit `style: apply formatter` and push to the PR +# head, then set output committed=true so the caller skips portions on SHA_old. +# Do not --no-verify. Do not push to main. +# +# Caller example: +# jobs: +# autofix: +# if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork +# uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@ # vX.Y.Z +# permissions: { contents: write } +# secrets: inherit +# with: +# format-command: npm run format +# lint-fix-command: npm run lint -- --fix +# +# Org secrets (names only): AUTOFMT_APP_ID, AUTOFMT_APP_PRIVATE_KEY. + +on: + workflow_call: + inputs: + format-command: + description: "Write formatter command (e.g. npm run format, ruff format .)" + type: string + required: true + lint-fix-command: + description: "Optional write lint-fix command (e.g. ruff check --fix .)" + type: string + required: false + default: "" + extra-command: + description: "Optional extra write command (e.g. terraform fmt -write)" + type: string + required: false + default: "" + node-version: + description: "Node.js version when package-lock.json is present" + type: string + required: false + default: "24" + terraform-version: + description: "Terraform version when extra-command mentions terraform" + type: string + required: false + default: "1.16.0" + outputs: + committed: + description: "true when this job pushed a formatter commit" + value: ${{ jobs.autofix.outputs.committed }} + secrets: + AUTOFMT_APP_ID: + description: "GitHub App id for the formatter" + required: true + AUTOFMT_APP_PRIVATE_KEY: + description: "GitHub App private key for the formatter" + required: true + +permissions: + contents: write + +jobs: + autofix: + name: autofix + if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ci-autofix-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + outputs: + committed: ${{ steps.result.outputs.committed }} + steps: + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.AUTOFMT_APP_ID }} + private-key: ${{ secrets.AUTOFMT_APP_PRIVATE_KEY }} + + - name: Skip App-authored synchronize + id: skip-bot + env: + ACTOR: ${{ github.actor }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + run: | + set -euo pipefail + expected="${APP_SLUG}[bot]" + if [ "${ACTOR}" = "${expected}" ]; then + echo "skip=true" >> "${GITHUB_OUTPUT}" + echo "Actor is ${expected}; not reformatting an App push." + else + echo "skip=false" >> "${GITHUB_OUTPUT}" + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: ${{ steps.skip-bot.outputs.skip != 'true' }} + with: + token: ${{ steps.app-token.outputs.token }} + ref: ${{ github.head_ref }} + persist-credentials: true + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' }} + with: + node-version: ${{ inputs.node-version }} + cache: npm + + - name: Install npm dependencies + if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' }} + run: npm ci + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + if: ${{ steps.skip-bot.outputs.skip != 'true' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff')) }} + with: + python-version: "3.12" + + - name: Install ruff + if: ${{ steps.skip-bot.outputs.skip != 'true' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff')) }} + run: pip install 'ruff==0.15.22' + + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + if: ${{ steps.skip-bot.outputs.skip != 'true' && contains(inputs.extra-command, 'terraform') }} + with: + terraform_version: ${{ inputs.terraform-version }} + terraform_wrapper: false + + - name: Apply formatter + if: ${{ steps.skip-bot.outputs.skip != 'true' }} + env: + FORMAT_COMMAND: ${{ inputs.format-command }} + LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }} + EXTRA_COMMAND: ${{ inputs.extra-command }} + run: | + set -euo pipefail + bash -euo pipefail -c "${FORMAT_COMMAND}" + if [ -n "${LINT_FIX_COMMAND}" ]; then + bash -euo pipefail -c "${LINT_FIX_COMMAND}" + fi + if [ -n "${EXTRA_COMMAND}" ]; then + bash -euo pipefail -c "${EXTRA_COMMAND}" + fi + + - name: Commit and push if dirty + id: result + env: + SKIP_BOT: ${{ steps.skip-bot.outputs.skip }} + HEAD_REF: ${{ github.head_ref }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + APP_ID: ${{ secrets.AUTOFMT_APP_ID }} + run: | + set -euo pipefail + + if [ "${SKIP_BOT}" = "true" ]; then + echo "committed=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + if [ -z "${HEAD_REF}" ] || [ "${HEAD_REF}" = "main" ]; then + echo "Refusing to push formatter commits to ${HEAD_REF:-empty}" >&2 + exit 1 + fi + + git config user.name "${APP_SLUG}[bot]" + git config user.email "${APP_ID}+${APP_SLUG}[bot]@users.noreply.github.com" + + if [ -z "$(git status --porcelain)" ]; then + echo "Tree is clean; no formatter commit." + echo "committed=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + git add -A + git commit -m "style: apply formatter" + git push origin "HEAD:refs/heads/${HEAD_REF}" + echo "committed=true" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/ci-frontend.yaml b/.github/workflows/ci-frontend.yaml new file mode 100644 index 0000000..60c1035 --- /dev/null +++ b/.github/workflows/ci-frontend.yaml @@ -0,0 +1,262 @@ +name: CI — Frontend + +# Parallel CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs +# with vitest + Playwright). Jobs: guard, static, build, unit (optional shards), +# browser-smoke. The caller owns the `ci-complete` aggregator and ruleset check. +# Do not put these portion names in an org ruleset. +# +# Remaining-lane repos that still need the sequential `ci / ci` context should +# keep calling ci-typescript-frontend.yaml until they migrate. +# +# Caller example: +# jobs: +# frontend: +# uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@ # vX.Y.Z +# with: +# node-version: "24" +# unit-shards: 4 +# run-e2e: true + +on: + workflow_call: + inputs: + node-version: + description: "Node.js version to use" + type: string + default: "24" + unit-shards: + description: "Vitest shard count (1-8). PR UI shows unit (1) .. unit (N)." + type: number + default: 1 + run-e2e: + description: "Run the test:e2e script (Playwright browser smoke)" + type: boolean + default: true + required-scripts: + description: "Comma-separated npm scripts that must exist in package.json" + type: string + default: "format:check,lint,build,test,test:e2e" + working-directory: + description: "Directory to run npm/build/test commands from" + type: string + default: "." + +permissions: + contents: read + +jobs: + guard: + name: guard + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-guard + cancel-in-progress: true + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify unit-shards + env: + UNIT_SHARDS: ${{ inputs.unit-shards }} + run: | + set -euo pipefail + if [ "${UNIT_SHARDS}" -lt 1 ] || [ "${UNIT_SHARDS}" -gt 8 ]; then + echo "unit-shards must be between 1 and 8 (got ${UNIT_SHARDS})" >&2 + exit 1 + fi + + - name: Verify required npm scripts + env: + REQUIRED_SCRIPTS: ${{ inputs.required-scripts }} + RUN_E2E: ${{ inputs.run-e2e }} + run: | + node <<'NODE' + const { readFileSync } = require("node:fs"); + const pkg = JSON.parse(readFileSync("package.json", "utf8")); + const required = (process.env.REQUIRED_SCRIPTS || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .filter((script) => process.env.RUN_E2E !== "false" || script !== "test:e2e"); + const missing = required.filter((script) => !pkg.scripts?.[script]); + + if (missing.length > 0) { + console.error(`Missing required scripts: ${missing.join(", ")}`); + process.exit(1); + } + console.log(`All required scripts present: ${required.join(", ")}`); + NODE + + - name: Guard changed lines + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE: ${{ github.event.before }} + MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + set -euo pipefail + + if [ "${EVENT_NAME}" = "pull_request" ]; then + BASE_REF="${PR_BASE_SHA}" + elif [ "${EVENT_NAME}" = "merge_group" ]; then + BASE_REF="${MERGE_GROUP_BASE_SHA}" + else + BASE_REF="${PUSH_BEFORE}" + fi + + if [ -z "${BASE_REF}" ] || [ "${BASE_REF}" = "0000000000000000000000000000000000000000" ]; then + BASE_REF="$(git rev-parse HEAD~1 2>/dev/null || true)" + fi + + if [ -z "${BASE_REF}" ]; then + echo "No base ref available; skipping changed-line guard." + exit 0 + fi + + ADDED_LINES="$(git diff --unified=0 "${BASE_REF}" "${GITHUB_SHA}" | grep -E '^\+' | grep -vE '^\+\+\+' || true)" + + if printf '%s\n' "${ADDED_LINES}" | grep -E 'Generated with (Claude [C]ode|[C]odex|Chat[G]PT)|--no-[v]erify|HUSKY[=]0'; then + echo "Found generated-tool footer or hook bypass wording in added lines." + exit 1 + fi + + if printf '%s\n' "${ADDED_LINES}" | grep -E 'A[K]IA[0-9A-Z]{16}|A[S]IA[0-9A-Z]{16}|AWS[_]SECRET[_]ACCESS[_]KEY|aws[_-]?secret[_-]?access[_-]?key|J[W]TAuthenticationHIGHsecuredPassword|P[a]ssword=|S[e]ndGrid.*A[p]iKey'; then + echo "Found a likely secret in added lines. Move sensitive values to the environment or secret manager." + exit 1 + fi + + echo "Changed-line guard passed." + + - name: Conventions check + working-directory: ${{ github.workspace }} + run: | + errors=0 + fail() { echo "::error::$1"; errors=$((errors + 1)); } + [[ -f README.md ]] || fail "Missing README.md" + if [[ -f .gitignore ]]; then + grep -qE '^\.env$|^\.env\b' .gitignore || fail ".gitignore does not include .env" + else + fail "Missing .gitignore" + fi + if [[ $errors -gt 0 ]]; then + echo "Conventions check failed with $errors error(s)." + exit 1 + fi + echo "Conventions check passed." + + static: + name: static + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-static + cancel-in-progress: true + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }} + + - run: npm ci + - run: npm run format:check + - run: npm run lint + + build: + name: build + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-build + cancel-in-progress: true + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }} + + - run: npm ci + - run: npm run build + + unit: + name: unit (${{ matrix.shard }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-unit-${{ matrix.shard }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + shard: ${{ fromJSON(format('[{0}]', inputs.unit-shards == 1 && '1' || inputs.unit-shards == 2 && '1,2' || inputs.unit-shards == 3 && '1,2,3' || inputs.unit-shards == 4 && '1,2,3,4' || inputs.unit-shards == 5 && '1,2,3,4,5' || inputs.unit-shards == 6 && '1,2,3,4,5,6' || inputs.unit-shards == 7 && '1,2,3,4,5,6,7' || '1,2,3,4,5,6,7,8')) }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }} + + - run: npm ci + - name: Unit tests + env: + SHARD: ${{ matrix.shard }} + SHARDS: ${{ inputs.unit-shards }} + run: npm test -- --shard="${SHARD}/${SHARDS}" + + browser-smoke: + name: browser-smoke + if: ${{ inputs.run-e2e }} + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: ci-frontend-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-browser-smoke + cancel-in-progress: true + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: ${{ inputs.working-directory == '.' && 'package-lock.json' || format('{0}/package-lock.json', inputs.working-directory) }} + + - run: npm ci + - name: Browser smoke + env: + CI: "true" + run: | + npx playwright install --with-deps chromium + npm run test:e2e diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml new file mode 100644 index 0000000..9935d1c --- /dev/null +++ b/.github/workflows/ci-terraform.yaml @@ -0,0 +1,57 @@ +name: CI — Terraform + +# Reusable Terraform fmt/init/validate for HCP app repos. Init uses +# `-backend=false` so CI does not need remote state credentials. The caller +# owns the `ci-complete` aggregator. +# +# Caller example: +# jobs: +# terraform: +# uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@ # vX.Y.Z +# with: +# terraform-version: "1.16.0" + +on: + workflow_call: + inputs: + terraform-version: + description: "Terraform version to install" + type: string + default: "1.16.0" + working-directory: + description: "Directory containing Terraform sources" + type: string + default: "terraform" + +permissions: + contents: read + +jobs: + terraform: + name: terraform + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ci-terraform-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }} + cancel-in-progress: true + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: ${{ inputs.terraform-version }} + terraform_wrapper: false + + - name: Terraform fmt + run: terraform fmt -check -recursive + + - name: Terraform init + run: terraform init -backend=false + + - name: Terraform validate + run: terraform validate diff --git a/.github/workflows/ci-typescript-frontend.yaml b/.github/workflows/ci-typescript-frontend.yaml index 4c7c1a4..733b137 100644 --- a/.github/workflows/ci-typescript-frontend.yaml +++ b/.github/workflows/ci-typescript-frontend.yaml @@ -1,9 +1,11 @@ name: CI — TypeScript Frontend -# Reusable CI for bundled TypeScript front-end apps (Vite / React / Vue SPAs -# with vitest + Playwright). Emits the single `ci / ci` status context required -# by the org branch-protection rulesets — keep the caller job id `ci` so the -# context resolves to `ci / ci`. +# Sequential reusable CI for remaining-lane TypeScript front-end apps that +# still emit `ci / ci`. HCP app repos should call ci-frontend.yaml (parallel +# portions) plus a caller-owned `ci-complete` aggregator instead. +# +# Emits the single `ci / ci` status context required by the unconverted-repo +# ruleset — keep the caller job id `ci` so the context resolves to `ci / ci`. # # Runs, in order: a Sea Haven standards gate (required npm scripts present, no # AI-tool footers / hook bypasses / hardcoded secrets in the added lines), diff --git a/.mergify.yml b/.mergify.yml deleted file mode 100644 index 8212e3c..0000000 --- a/.mergify.yml +++ /dev/null @@ -1,44 +0,0 @@ -merge_queue: - mode: serial - max_parallel_checks: 5 - queue_controls_comment: false - -merge_protections_settings: - auto_merge_conditions: - - base = main - - -draft - - github-review-decision = APPROVED - - check-success = "ci / ci" - -merge_protections: - - name: require-review-and-ci - if: - - base = main - - -draft - success_conditions: - - github-review-decision = APPROVED - - check-success = "ci / ci" - -queue_rules: - - name: default - batch_size: 3 - batch_max_wait_time: 30 seconds - checks_timeout: 10 min - queue_conditions: - - base = main - - -draft - - github-review-decision = APPROVED - - check-success = "ci / ci" - merge_method: squash - commit_message_format: - title: inherit - body: empty - branch_protection_injection_mode: queue - -pull_request_rules: - - name: queue on queue ready label - conditions: - - label = "queue ready" - actions: - queue: - name: default diff --git a/README.md b/README.md index 750b34c..138b2c2 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,16 @@ The two sanctioned deploy paths are merge to `main` triggering the pipeline and ### Merge queue -Consumer repos extend `.mergify.yml` via `extends: .github`. Mergify auto-queues when it is awake. The default queue tests up to three PRs together on a draft branch so it does not push onto the original PR. Each PR still squash-merges on its own. Merge protections skip those drafts. Pending queue checks time out after 10 minutes. To kick a stuck PR, apply the `queue ready` label. That does not bypass `ci / ci` or `APPROVED`. Do not use `queued`; Mergify applies that while a PR is in the queue. +CI callers keep a `merge_group` trigger so native GitHub merge queues still run portions. Mergify YAML is not used. Do not put portion job names (`frontend / static`, `unit (1)`, …) in a ruleset. + +### Required checks + +Two org rulesets. A repo is on exactly one of them: + +- **main branch protection** requires `ci / ci` for unconverted remaining-lane repos. +- **CI complete** requires `ci-complete` for converted HCP callers. It targets no repos until a cutover includes the repo and excludes it from the old ruleset in the same window. + +The formatter GitHub App is not on the main-branch bypass list. ## What's in here @@ -36,7 +45,17 @@ Consumer repos extend `.mergify.yml` via `extends: .github`. Mergify auto-queues **`.github/workflows/ci-typescript-cdk.yaml`** — Reusable CI workflow for TypeScript / CDK repos. Runs `npm ci` + optional `tsc --noEmit`, optional ESLint, optional Jest, and optional `cdk synth`. Also supports Node.js SAM repos via an optional `sam validate` step. -**`.github/workflows/ci-typescript-frontend.yaml`** — Reusable CI workflow for bundled TypeScript front-end apps (Vite / React / Vue SPAs). Runs a Sea Haven standards gate (required npm scripts present, no AI-tool footers / hook bypasses / hardcoded secrets in added lines), then `format:check`, `lint`, `build`, vitest unit tests, and an optional Playwright browser smoke. Emits the single `ci / ci` status context — keep the caller job id `ci`. +**`.github/workflows/ci-typescript-frontend.yaml`** — Sequential reusable CI for remaining-lane bundled TypeScript front-end apps that still emit `ci / ci`. HCP app repos should call `ci-frontend.yaml` plus a caller-owned `ci-complete` aggregator instead. + +**`.github/workflows/ci-frontend.yaml`** — Parallel HCP frontend CI: `guard`, `static`, `build`, `unit` (optional shards), `browser-smoke`. The caller owns `ci-complete`. Do not put those portion names in a ruleset. + +**`.github/workflows/ci-terraform.yaml`** — Terraform `fmt -check`, `init -backend=false`, and `validate`. Default version `1.16.0`. + +**`.github/workflows/ci-autofix.yaml`** — Pull-request-only formatter. Mints a GitHub App token (`AUTOFMT_APP_ID`, `AUTOFMT_APP_PRIVATE_KEY`), runs the caller's write commands, and pushes `style: apply formatter` only when the tree is dirty. 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-hcp-spa.yaml`** — HCP SPA CD. Checkout at `ref`, Node 24, `npm ci` + `npm run build`, origin-path guard, hashed s3 sync then `index.html` last then prune, invalidate, verify. Build env comes from the GitHub Environment (`vars.VITE_*`, `secrets.SENTRY_AUTH_TOKEN`). **`.github/workflows/cd-sam.yaml`** — Reusable CD workflow for SAM repos. Runs `sam build` + `sam deploy` with OIDC credentials and a CloudFormation execution role. Triggers via `workflow_call` from per-repo `deploy.yaml` on push to `main`. @@ -74,7 +93,7 @@ The supply-chain check operates in **diff mode**: for modified or renamed workfl ### Workflow templates (`workflow-templates/`) -Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `labeler`, `mobile-ios-deploy`, `pr-policy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one. +Starter workflows offered on the org's **Actions → New workflow** page: `cdk-deploy`, `ci-dotnet`, `ci-hcp`, `ci-mobile-ios`, `ci-node`, `ci-python`, `ci-python-app`, `ci-static`, `ci-terraform`, `ci-typescript-frontend`, `dependency-review`, `dotnet-eb-deploy`, `hcp-fargate-deploy`, `hcp-spa-deploy`, `labeler`, `mobile-ios-deploy`, `pr-policy`, `release`, `sam-deploy`, `triage`. Each is a thin caller of the corresponding reusable workflow above (`triage` is standalone; `ci-hcp` is the converted-repo caller with autofix, frontend, terraform, and `ci-complete`). Every template has a paired `properties.json` (name, description, icon, `filePatterns` for auto-suggestion). Replace any `REPLACE-ME` placeholders before enabling. Templates are not scanned by Dependabot, so refresh their pinned SHAs opportunistically when editing one. A `pr-policy` starter template is available in `workflow-templates/`. Before the template produces passing human PR checks, the three Jira org secrets must be granted to the consumer repo (see §1). @@ -166,8 +185,10 @@ Managed under **Organization Settings > Secrets and variables > Actions**. Each | Secret | Value | Consumed by | |--------|-------|-------------| | `ANTHROPIC_API_KEY` | Anthropic API key | `reviewer-eval.yml` in `open-swe` | +| `AUTOFMT_APP_ID` | Formatter GitHub App id | `ci-autofix.yaml` | +| `AUTOFMT_APP_PRIVATE_KEY` | Formatter GitHub App private key | `ci-autofix.yaml` | -The CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §3). +The remaining-lane CI and CD workflows below need no org secret — CD authenticates to AWS via OIDC using the per-repo `AWS_DEPLOY_ROLE_ARN` secret (see §4). HCP CD uses `vars.DEPLOY_ROLE_ARN` on the GitHub Environment after OIDC. Adam installs the formatter App (contents: write, metadata: read; not a main-branch ruleset bypass) and grants the two autofmt secrets before the first converted repo runs autofix. Three additional org-level secrets are required for the PR policy Jira check. Set each to **selected repositories** visibility and grant to each consumer repo: @@ -242,6 +263,65 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4 ``` +**HCP app repo** (converted callers; required check is `ci-complete`): + +```yaml +name: CI +on: + pull_request: + branches: [main, hotfix/**, release/**] + merge_group: + push: + branches: [hotfix/**, release/**] + +permissions: + contents: read + +jobs: + autofix: + if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork + uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@ # vX.Y.Z + permissions: { contents: write } + secrets: inherit + with: + format-command: npm run format + lint-fix-command: npm run lint -- --fix + + frontend: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@ # vX.Y.Z + with: + node-version: "24" + unit-shards: 4 + run-e2e: true + + terraform: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@ # vX.Y.Z + with: + terraform-version: "1.16.0" + + ci-complete: + name: ci-complete + needs: [autofix, frontend, terraform] + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require portions + env: + FRONTEND: ${{ needs.frontend.result }} + TERRAFORM: ${{ needs.terraform.result }} + run: | + set -euo pipefail + test "${FRONTEND}" = success + test "${TERRAFORM}" = success +``` + +Python HCP callers pass `format-command: ruff format .` and `lint-fix-command: ruff check --fix .`. Optional `extra-command: terraform fmt -write` is available on `ci-autofix.yaml`. Do not run `eslint --fix` unless that repo's `lint` script is already fix-safe. Flip org ruleset membership in the same window as this merge: include on `CI complete`, exclude from `main branch protection`. Never require both `ci / ci` and `ci-complete`. Do not edit native GitHub merge-queue rulesets. + ### 3. Add PR policy to a repo Create `.github/workflows/policy.yaml` in the target repo. The Jira secrets must already be granted to the repo (see §1). @@ -282,9 +362,58 @@ The check-run name is `policy / pr`. If your branch-protection ruleset requires ### 4. Add CD to a repo -Create `.github/workflows/deploy.yaml` in the target repo. Requires `AWS_DEPLOY_ROLE_ARN` repo secret. +**HCP Fargate** (one caller job per GitHub Environment; `environment` is a `with:` input): -**SAM repo** (e.g., afterhours-shift-manager): +```yaml +name: Deploy API +on: + push: + branches: [main] + paths-ignore: [terraform/**, docs/**, "*.md"] + release: + types: [published] + workflow_dispatch: + inputs: + environment: { type: choice, options: [dev, prod] } + ref: { type: string, default: "" } + +permissions: + contents: read + +jobs: + deploy-dev: + name: Deploy API to dev + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@ # vX.Y.Z + permissions: { contents: read, id-token: write } + secrets: inherit + with: + environment: dev + ref: ${{ inputs.ref }} + ssm-prefix: /meal-order-manager/deploy + docker-platform: linux/amd64 + extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}' + + deploy-prod: + name: Deploy API to prod + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@ # vX.Y.Z + permissions: { contents: read, id-token: write } + secrets: inherit + with: + environment: prod + ref: ${{ github.event.release.tag_name || inputs.ref }} + ssm-prefix: /meal-order-manager/deploy + docker-platform: linux/amd64 + ship-gate: true + extra-task-env: '{"SENTRY_DSN_PARAM":"/meal-order-manager/sentry-dsn"}' +``` + +SPA callers use `cd-hcp-spa.yaml` the same way. Pass `required-vite-vars` for Environment `VITE_*` keys that must be set before `npm run build`. Add `deploy-staging` only where that Environment exists. `DEPLOY_ROLE_ARN` is a GitHub Environment variable, not a repo secret. SHA-pinned org reusables change `job_workflow_ref` to `Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@` (and spa). Keep `workflow_ref` on the thin caller at `refs/heads/main` and `refs/tags/v*`. `sub` stays `repo:.../:environment:`. Adding a reusable is a cross-family IAM change. + +Create `.github/workflows/deploy.yaml` in remaining SAM/CDK repos. Requires `AWS_DEPLOY_ROLE_ARN` repo secret. + +**SAM repo** (e.g., remaining SAM stacks): ```yaml name: Deploy diff --git a/workflow-templates/ci-hcp.properties.json b/workflow-templates/ci-hcp.properties.json new file mode 100644 index 0000000..c6b05fd --- /dev/null +++ b/workflow-templates/ci-hcp.properties.json @@ -0,0 +1,7 @@ +{ + "name": "Sea Haven — CI (HCP)", + "description": "Parallel frontend + Terraform CI with autofix and a ci-complete aggregator for converted HCP app repos. Remaining-lane SPAs should keep the sequential TypeScript frontend template.", + "iconName": "octicon-checklist", + "categories": ["TypeScript", "JavaScript", "Continuous integration"], + "filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "terraform/.*\\.tf$"] +} diff --git a/workflow-templates/ci-hcp.yml b/workflow-templates/ci-hcp.yml new file mode 100644 index 0000000..79df36b --- /dev/null +++ b/workflow-templates/ci-hcp.yml @@ -0,0 +1,53 @@ +name: CI +on: + pull_request: + branches: [main, hotfix/**, release/**] + merge_group: + push: + branches: [hotfix/**, release/**] + +permissions: + contents: read + +jobs: + autofix: + if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork + uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@REPLACE-ME # vX.Y.Z + permissions: + contents: write + secrets: inherit + with: + format-command: npm run format + lint-fix-command: "npm run lint -- --fix" + + frontend: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + uses: Sea-Haven-Industries/.github/.github/workflows/ci-frontend.yaml@REPLACE-ME # vX.Y.Z + with: + node-version: "24" + unit-shards: 4 + run-e2e: true + + terraform: + needs: autofix + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@REPLACE-ME # vX.Y.Z + with: + terraform-version: "1.16.0" + + ci-complete: + name: ci-complete + needs: [autofix, frontend, terraform] + if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require portions + env: + FRONTEND: ${{ needs.frontend.result }} + TERRAFORM: ${{ needs.terraform.result }} + run: | + set -euo pipefail + test "${FRONTEND}" = success + test "${TERRAFORM}" = success diff --git a/workflow-templates/ci-terraform.properties.json b/workflow-templates/ci-terraform.properties.json new file mode 100644 index 0000000..bf7b9af --- /dev/null +++ b/workflow-templates/ci-terraform.properties.json @@ -0,0 +1,7 @@ +{ + "name": "Sea Haven — CI (Terraform)", + "description": "Runs terraform fmt -check, init -backend=false, and validate via the org reusable ci-terraform workflow. Prefer the HCP CI template when the repo also has a frontend.", + "iconName": "octicon-checklist", + "categories": ["Continuous integration"], + "filePatterns": ["terraform/.*\\.tf$"] +} diff --git a/workflow-templates/ci-terraform.yml b/workflow-templates/ci-terraform.yml new file mode 100644 index 0000000..29beb5d --- /dev/null +++ b/workflow-templates/ci-terraform.yml @@ -0,0 +1,16 @@ +name: Terraform CI +on: + pull_request: + branches: [main, hotfix/**, release/**] + merge_group: + push: + branches: [hotfix/**, release/**] + +permissions: + contents: read + +jobs: + terraform: + uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@REPLACE-ME # vX.Y.Z + with: + terraform-version: "1.16.0" diff --git a/workflow-templates/ci-typescript-frontend.properties.json b/workflow-templates/ci-typescript-frontend.properties.json index 73d8113..bcc5ea8 100644 --- a/workflow-templates/ci-typescript-frontend.properties.json +++ b/workflow-templates/ci-typescript-frontend.properties.json @@ -1,6 +1,6 @@ { "name": "Sea Haven — CI (TypeScript / frontend)", - "description": "Runs the Sea Haven standards gate, format:check, lint, build, unit tests, and a Playwright browser smoke for bundled Vite/React/Vue apps via the org reusable workflow.", + "description": "Sequential remaining-lane CI that emits ci / ci. Converted HCP SPAs should use the HCP CI template (ci-frontend plus ci-complete) instead.", "iconName": "octicon-checklist", "categories": ["TypeScript", "JavaScript", "Continuous integration"], "filePatterns": ["package\\.json$", "vite\\.config\\.[jt]s$", "playwright\\.config\\.[jt]s$"] diff --git a/workflow-templates/hcp-fargate-deploy.properties.json b/workflow-templates/hcp-fargate-deploy.properties.json new file mode 100644 index 0000000..ab6358d --- /dev/null +++ b/workflow-templates/hcp-fargate-deploy.properties.json @@ -0,0 +1,7 @@ +{ + "name": "Sea Haven — Deploy (HCP Fargate)", + "description": "Deploys a Fargate image via the org reusable cd-hcp-fargate workflow. One caller job per GitHub Environment. Push to main deploys dev; a published Release deploys prod behind ship-gate.", + "iconName": "octicon-rocket", + "categories": ["Deployment", "Docker", "Continuous integration"], + "filePatterns": ["Dockerfile$", "terraform/.*\\.tf$"] +} diff --git a/workflow-templates/hcp-fargate-deploy.yml b/workflow-templates/hcp-fargate-deploy.yml new file mode 100644 index 0000000..7785cba --- /dev/null +++ b/workflow-templates/hcp-fargate-deploy.yml @@ -0,0 +1,54 @@ +name: Deploy API +on: + push: + branches: [main] + paths-ignore: [terraform/**, docs/**, "*.md"] + release: + types: [published] + workflow_dispatch: + inputs: + environment: + description: "Target Environment" + required: true + type: choice + options: [dev, prod] + ref: + description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref." + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + deploy-dev: + name: Deploy API to dev + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@REPLACE-ME # vX.Y.Z + permissions: + contents: read + id-token: write + secrets: inherit + with: + environment: dev + ref: ${{ inputs.ref }} + ssm-prefix: /REPLACE-ME-repo/deploy + docker-platform: linux/amd64 + # extra-task-env: '{"SENTRY_DSN_PARAM":"/REPLACE-ME-repo/sentry-dsn"}' + + deploy-prod: + name: Deploy API to prod + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@REPLACE-ME # vX.Y.Z + permissions: + contents: read + id-token: write + secrets: inherit + with: + environment: prod + ref: ${{ github.event.release.tag_name || inputs.ref }} + ssm-prefix: /REPLACE-ME-repo/deploy + docker-platform: linux/amd64 + ship-gate: true + # extra-task-env: '{"SENTRY_DSN_PARAM":"/REPLACE-ME-repo/sentry-dsn"}' diff --git a/workflow-templates/hcp-spa-deploy.properties.json b/workflow-templates/hcp-spa-deploy.properties.json new file mode 100644 index 0000000..8f6aa5d --- /dev/null +++ b/workflow-templates/hcp-spa-deploy.properties.json @@ -0,0 +1,7 @@ +{ + "name": "Sea Haven — Deploy (HCP SPA)", + "description": "Deploys a Vite SPA to S3/CloudFront via the org reusable cd-hcp-spa workflow. One caller job per GitHub Environment. Add a deploy-staging job only when that Environment exists.", + "iconName": "octicon-rocket", + "categories": ["Deployment", "TypeScript", "JavaScript"], + "filePatterns": ["vite\\.config\\.[jt]s$", "package\\.json$"] +} diff --git a/workflow-templates/hcp-spa-deploy.yml b/workflow-templates/hcp-spa-deploy.yml new file mode 100644 index 0000000..f8efe45 --- /dev/null +++ b/workflow-templates/hcp-spa-deploy.yml @@ -0,0 +1,51 @@ +name: Deploy Web +on: + push: + branches: [main] + paths-ignore: [terraform/**, docs/**, "*.md"] + release: + types: [published] + workflow_dispatch: + inputs: + environment: + description: "Target Environment" + required: true + type: choice + options: [dev, prod] + ref: + description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref." + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + deploy-dev: + name: Deploy SPA to dev + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-spa.yaml@REPLACE-ME # vX.Y.Z + permissions: + contents: read + id-token: write + secrets: inherit + with: + environment: dev + ref: ${{ inputs.ref }} + ssm-prefix: /REPLACE-ME-repo/deploy + + deploy-prod: + name: Deploy SPA to prod + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod') + uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-spa.yaml@REPLACE-ME # vX.Y.Z + permissions: + contents: read + id-token: write + secrets: inherit + with: + environment: prod + ref: ${{ github.event.release.tag_name || inputs.ref }} + ssm-prefix: /REPLACE-ME-repo/deploy + ship-gate: true + # required-vite-vars: "VITE_SHIFTS_API_BASE,VITE_SENTRY_DSN"