From d154915a368372a83c5dbc8333a8c6891217ed32 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:42:28 +0000 Subject: [PATCH] feat(cd): add Python HCP Lambda zip deploy reusable (PLAT-251) (#161) * feat(cd): add Python HCP Lambda zip deploy reusable (PLAT-251) cd-hcp-lambda-python.yaml mirrors cd-hcp-lambda.yaml for repos whose packager is a shell script. The caller provides scripts/package_lambdas.sh --git-sha --out-dir --only , which writes build/packages/.zip with build_info.py carrying the commit. The ship-gate step is unchanged. After update-function-code settles, each function's CodeSha256 must equal the base64 SHA-256 of the local zip and LastUpdateStatus must be Successful. These functions have no health URL, so the digest is the live-state check. * chore(ci): retrigger checks after the GitHub Actions incident --- .github/workflows/cd-hcp-lambda-python.yaml | 291 ++++++++++++++++++++ README.md | 4 + 2 files changed, 295 insertions(+) create mode 100644 .github/workflows/cd-hcp-lambda-python.yaml diff --git a/.github/workflows/cd-hcp-lambda-python.yaml b/.github/workflows/cd-hcp-lambda-python.yaml new file mode 100644 index 0000000..07ec987 --- /dev/null +++ b/.github/workflows/cd-hcp-lambda-python.yaml @@ -0,0 +1,291 @@ +name: CD — HCP Lambda (Python) + +# Reusable Python 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-python.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: /paychex-integrations/deploy +# function-keys: webhook_ingest,webhook_processor +# ship-gate: true +# +# The caller repo must provide scripts/package_lambdas.sh, which accepts +# `--git-sha --out-dir --only ...`, writes /.zip, +# and embeds the commit in build_info.py inside each zip. Terraform owns the +# functions and ignores code attributes. SSM under ssm-prefix supplies +# artifacts-bucket and -function-name. +# +# Lambda reports CodeSha256 as the base64 SHA-256 of the uploaded zip. After +# update-function-code settles, each function's CodeSha256 must equal the +# local zip digest. That is the live-state check for functions with no +# health URL. +# +# 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 + python-version: + description: "Python version for setup-python and the packager" + type: string + required: false + default: "3.12" + 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 + # Newest matching release that is an ancestor of TAG. The highest + # release overall is not that ancestor when a hotfix is cut from an + # older line (v2.0.0 exists, v1.2.1 is cut from v1.2.0). + CANDIDATES="$( + 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, reverse=True) + print("\n".join(tags)) + ' + )" + + PREV="" + if [ -n "${CANDIDATES}" ]; then + while IFS= read -r candidate; do + if [ -z "${candidate}" ]; then + continue + fi + candidate_status="$(gh api "repos/${REPO}/compare/${candidate}...${TAG}" --jq .status)" + if [ "${candidate_status}" = "ahead" ]; then + PREV="${candidate}" + break + fi + done <<< "${CANDIDATES}" + fi + + if [ -z "${PREV}" ]; then + echo "ship-gate: no prior ${PATTERN} release is an ancestor of ${TAG}" >&2 + exit 1 + fi + + echo "ship-gate: ${TAG} is ahead of ${PREV}" + + 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-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ inputs.python-version }} + + - 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=(bash scripts/package_lambdas.sh --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("build_info.py").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}" + expected="$(openssl dgst -sha256 -binary "build/packages/${key}.zip" | base64)" + read -r actual status < <(aws lambda get-function-configuration --function-name "${fn}" \ + --query '[CodeSha256,LastUpdateStatus]' --output text) + if [ "${status}" != "Successful" ]; then + echo "${fn}: LastUpdateStatus is ${status}" >&2 + exit 1 + fi + if [ "${actual}" != "${expected}" ]; then + echo "${fn}: CodeSha256 ${actual} does not match local zip ${expected}" >&2 + exit 1 + fi + echo "${fn}: CodeSha256 ${actual} matches ${GIT_SHA}" + done diff --git a/README.md b/README.md index ec4fc6b..42dc097 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ The formatter GitHub App is not on the main-branch bypass list. **`.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-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. + **`.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-hcp-static.yaml`** — HCP static-site CD for unfingerprinted builds such as Eleventy. Checkout at `ref`, Node 24, `build-command` (default `npm ci --ignore-scripts && npm run build`), one-day cache on assets, `no-cache` on HTML/XML/text, prune, invalidate, verify the served hash at `verify-path` (default `/`). Reads `//bucket` and `//distribution-id`. `index-required: false` drops the `index.html` checks for trees that have no index page, such as an MTA-STS policy host; a matrix caller can deploy several prefixes from one workflow. Do not use this for a hashed SPA.