feat(ci): add HCP Lambda zip deploy reusable (PLAT-79)

This commit is contained in:
Adam Moussa 2026-09-28 15:18:57 -04:00
parent 892580d7d7
commit 1528c01e94
No known key found for this signature in database

261
.github/workflows/cd-hcp-lambda.yaml vendored Normal file
View file

@ -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@<sha> # 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/<key>.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 <key>-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 <prefix>/<key>-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