From 1528c01e94b7d93ab4ccd74fb2162e61fda23a71 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 28 Sep 2026 15:18:57 -0400 Subject: [PATCH] feat(ci): add HCP Lambda zip deploy reusable (PLAT-79) --- .github/workflows/cd-hcp-lambda.yaml | 261 +++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 .github/workflows/cd-hcp-lambda.yaml diff --git a/.github/workflows/cd-hcp-lambda.yaml b/.github/workflows/cd-hcp-lambda.yaml new file mode 100644 index 0000000..ced3a1e --- /dev/null +++ b/.github/workflows/cd-hcp-lambda.yaml @@ -0,0 +1,261 @@ +name: CD — HCP Lambda + +# Reusable Lambda zip 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-lambda.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: /payments-dashboard/deploy +# function-keys: process_csv,slack_app_home +# ship-gate: true +# +# The caller repo must provide scripts/package_lambdas.mjs, which writes +# build/packages/.zip and embeds the commit in src/buildInfo.js. +# Terraform owns the functions and ignores code attributes. SSM under +# ssm-prefix supplies artifacts-bucket and -function-name. +# +# Nothing here creates an HCP run. + +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. /payments-dashboard/deploy)" + type: string + required: true + function-keys: + description: "Comma-separated package keys. Each maps to SSM /-function-name." + type: string + required: true + node-version: + description: "Node.js version for setup-node and the packager" + type: string + required: false + default: "24" + 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: Deploy Lambda 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})" + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + + - name: Build function zips + env: + GIT_SHA: ${{ steps.commit.outputs.sha }} + FUNCTION_KEYS: ${{ inputs.function-keys }} + run: | + set -euo pipefail + if [ -z "${FUNCTION_KEYS}" ]; then + echo "function-keys is required" >&2 + exit 1 + fi + keys=() + IFS=',' read -r -a raw_keys <<< "${FUNCTION_KEYS}" + for raw in "${raw_keys[@]}"; do + key="${raw#"${raw%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + if [ -z "${key}" ]; then + echo "function-keys contains an empty key" >&2 + exit 1 + fi + if [[ ! "${key}" =~ ^[A-Za-z0-9_]+$ ]]; then + echo "invalid function key: ${key}" >&2 + exit 1 + fi + keys+=("${key}") + done + if [ "${#keys[@]}" -eq 0 ]; then + echo "function-keys is empty" >&2 + exit 1 + fi + clean="$(IFS=,; echo "${keys[*]}")" + echo "keys=${clean}" >> "${GITHUB_ENV}" + cmd=(node scripts/package_lambdas.mjs --git-sha "${GIT_SHA}" --out-dir build/packages) + for key in "${keys[@]}"; do + cmd+=(--only "${key}") + done + "${cmd[@]}" + FUNCTION_KEYS_CLEAN="${clean}" python3 - <<'PY' + import os, zipfile + from pathlib import Path + sha = os.environ["GIT_SHA"] + keys = [part for part in os.environ["FUNCTION_KEYS_CLEAN"].split(",") if part] + for name in keys: + path = Path("build/packages") / f"{name}.zip" + if not path.is_file(): + raise SystemExit(f"missing {path}") + with zipfile.ZipFile(path) as zf: + info = zf.read("src/buildInfo.js").decode() + if sha not in info: + raise SystemExit(f"{path} missing GIT_SHA {sha}") + print("zips ok") + PY + + - 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: Upload zips and update function code + env: + SSM_PREFIX: ${{ inputs.ssm-prefix }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + FUNCTION_KEYS_CLEAN: ${{ env.keys }} + run: | + set -euo pipefail + prefix="${SSM_PREFIX%/}" + bucket="$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text)" + IFS=',' read -r -a keys <<< "${FUNCTION_KEYS_CLEAN}" + for key in "${keys[@]}"; do + fn="$(aws ssm get-parameter --name "${prefix}/${key}-function-name" --query Parameter.Value --output text)" + s3_key="functions/${key}/${GIT_SHA}.zip" + aws s3 cp "build/packages/${key}.zip" "s3://${bucket}/${s3_key}" + aws lambda update-function-code \ + --function-name "${fn}" \ + --s3-bucket "${bucket}" \ + --s3-key "${s3_key}" \ + --query '{Function:FunctionName,Sha256:CodeSha256,Updated:LastModified}' \ + --output table + aws lambda wait function-updated-v2 --function-name "${fn}" + done