Skip to content

ci(examples): assert exact status/body instead of liveness-only check #110

ci(examples): assert exact status/body instead of liveness-only check

ci(examples): assert exact status/body instead of liveness-only check #110

Workflow file for this run

name: Verify Examples
on:
pull_request:
branches:
- main
paths:
- "examples/**"
push:
branches:
- main
paths:
- "src/**"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
validate:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.13"
- uses: aws-actions/setup-sam@v2
with:
use-installer: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Validate all SAM templates
run: |
failed=0
for template in $(find examples -maxdepth 2 -name "template.yaml" | sort); do
dir=$(dirname "$template")
echo "Validating $dir..."
if ! sam validate --template "$template" --lint 2>&1; then
echo "FAIL: $dir"
failed=1
fi
done
if [ "$failed" -eq 1 ]; then
echo "Some templates failed validation"
exit 1
fi
build-layer:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install stable toolchain
run: rustup target add x86_64-unknown-linux-musl
- name: Install cargo lambda
run: pip3 install cargo-lambda
- name: Configure Rust cache
uses: Swatinem/rust-cache@v2
- name: Build x86_64 layer
run: |
cargo lambda build --release --extension --target x86_64-unknown-linux-musl
mkdir -p layer-x86_64
cp layer/bootstrap layer-x86_64/
cp target/lambda/extensions/lambda-adapter layer-x86_64/
- uses: actions/upload-artifact@v4
with:
name: layer-x86_64
path: layer-x86_64/
# Docker-based examples verified with sam local start-api. Each matrix entry
# asserts an exact status (and optional body substring) via verify-http.sh,
# not just liveness.
# Excluded: nginx, php, flask, aspnet-mvc (web app hardcodes port 8080
# which conflicts with SAM's Lambda Runtime Interface Emulator).
test-image:
needs: [build-layer]
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
example:
- { name: expressjs, path: /, expect_body: "Hi there!" }
- { name: fastapi, path: /, expect_body: "message" }
- { name: fastapi-background-tasks, path: /, expect_body: "message" }
- { name: fasthtml, path: /, expect_body: "Hello World" }
- { name: gin, path: /, expect_body: "message" }
- { name: nextjs, path: /, expect_body: "Next.js Logo" }
- { name: remix, path: /, expect_body: "Welcome to" }
- { name: springboot, path: /v1/, expect_body: "Hello, world!" }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.13"
- uses: aws-actions/setup-sam@v2
with:
use-installer: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/download-artifact@v4
with:
name: layer-x86_64
path: /tmp/layer-x86_64
- name: Build local adapter image
run: |
# Build the local adapter under every tag the example Dockerfiles
# reference, so each example's `docker build` (COPY --from=...:<tag>)
# uses the locally compiled binary instead of pulling the released
# image. Deriving the tags from the Dockerfiles keeps CI in lockstep
# with them (no hardcoded version to forget at release time).
chmod +x /tmp/layer-x86_64/lambda-adapter
tags=$(grep -rhoE 'aws-lambda-adapter:[0-9A-Za-z._-]+' examples | sed 's/.*://' | sort -u)
echo "Building local adapter for tags: $tags"
for tag in $tags; do
printf 'FROM scratch\nCOPY --chmod=755 lambda-adapter /lambda-adapter\n' | \
docker build -t public.ecr.aws/awsguru/aws-lambda-adapter:$tag -f- /tmp/layer-x86_64
done
- name: Build
working-directory: examples/${{ matrix.example.name }}
run: |
# Retry to tolerate transient registry rate limits (toomanyrequests: Rate
# exceeded). public.ecr.aws throttles unauthenticated pulls at ~1 req/s per
# source IP, and the matrix runs many jobs from shared runner IPs, so bursts
# collide. Exponential backoff with random jitter desynchronizes retries
# across jobs to avoid a thundering herd re-colliding on the same boundary.
max_attempts=5
attempt=1
while true; do
if sam build; then exit 0; fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "sam build failed after ${max_attempts} attempts"
exit 1
fi
delay=$(( 10 * 2 ** (attempt - 1) + RANDOM % 16 )) # ~10/20/40/80s + 0-15s jitter
echo "sam build failed (attempt ${attempt}/${max_attempts}), retrying in ${delay}s..."
sleep "$delay"
attempt=$(( attempt + 1 ))
done
- name: Start local API and verify
working-directory: examples/${{ matrix.example.name }}
run: |
# Set PORT=8000 to avoid conflict with SAM's RIE on port 8080.
# Start and verify in the same step so the backgrounded sam local
# stays attached to this shell while requests are served (splitting
# it across steps races the container warm-up and yields 500s).
echo '{"Parameters":{"PORT":"8000"}}' > /tmp/env.json
sam local start-api --port 3000 --warm-containers EAGER --container-env-vars /tmp/env.json &
echo "SAM_PID=$!" >> $GITHUB_ENV
"$GITHUB_WORKSPACE/.github/scripts/verify-http.sh" \
http://127.0.0.1:3000 \
"${{ matrix.example.path }}" \
200 \
"${{ matrix.example.expect_body }}"
- name: Stop local API
if: always()
run: kill $SAM_PID 2>/dev/null || true
# Zip-based examples verified with local layer. Each matrix entry asserts an
# exact status (and optional body substring) via verify-http.sh.
# `port` is the app's listening port, passed through to the adapter via the
# PORT parameter; it must differ from SAM's RIE port (8080).
# Excluded: aspnet-*-zip (hardcode port 8080),
# bun/nginx/php-zip (need third-party layers), arm64 examples (javalin, rust-*),
# nextjs-zip (Makefile produces a zip artifact incompatible with sam local).
test-zip:
needs: [build-layer]
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
example:
- { name: deno-zip, path: /, expect_body: "success", port: "8000" }
- { name: expressjs-zip, path: /, expect_body: "Hi there!", port: "8000" }
- { name: fastapi-zip, path: /, expect_body: "message", port: "8000" }
- { name: fasthtml-zip, path: /, expect_body: "Hello World", port: "8000" }
- { name: flask-zip, path: /, expect_body: "message", port: "8000" }
- { name: gin-zip, path: /, expect_body: "message", port: "8000" }
- { name: remix-zip, path: /, expect_body: "Welcome to", port: "8000" }
- { name: springboot-zip, path: /v1/, expect_body: "Hello, world!", port: "8000" }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.13"
- uses: aws-actions/setup-sam@v2
with:
use-installer: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: denoland/setup-deno@v2
if: matrix.example.name == 'deno-zip'
with:
deno-version: v2.x
- uses: actions/download-artifact@v4
with:
name: layer-x86_64
path: /tmp/layer-x86_64
- name: Build
working-directory: examples/${{ matrix.example.name }}
run: |
# Retry to tolerate transient registry rate limits (toomanyrequests: Rate
# exceeded). public.ecr.aws throttles unauthenticated pulls at ~1 req/s per
# source IP, and the matrix runs many jobs from shared runner IPs, so bursts
# collide. Exponential backoff with random jitter desynchronizes retries
# across jobs to avoid a thundering herd re-colliding on the same boundary.
max_attempts=5
attempt=1
while true; do
if sam build; then exit 0; fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "sam build failed after ${max_attempts} attempts"
exit 1
fi
delay=$(( 10 * 2 ** (attempt - 1) + RANDOM % 16 )) # ~10/20/40/80s + 0-15s jitter
echo "sam build failed (attempt ${attempt}/${max_attempts}), retrying in ${delay}s..."
sleep "$delay"
attempt=$(( attempt + 1 ))
done
- name: Inject local layer into build output
working-directory: examples/${{ matrix.example.name }}
run: |
# Prepare local layer directory with correct structure
LAYER_DIR=".aws-sam/build/LocalAdapterLayer"
mkdir -p "$LAYER_DIR/extensions"
cp /tmp/layer-x86_64/bootstrap "$LAYER_DIR/"
cp /tmp/layer-x86_64/lambda-adapter "$LAYER_DIR/extensions/"
chmod +x "$LAYER_DIR/bootstrap" "$LAYER_DIR/extensions/lambda-adapter"
# Add local layer resource and replace remote layer ARN refs
pip install pyyaml -q
python3 << 'PYEOF'
import yaml
with open(".aws-sam/build/template.yaml") as f:
t = yaml.safe_load(f)
t["Resources"]["LocalAdapterLayer"] = {
"Type": "AWS::Lambda::LayerVersion",
"Properties": {"Content": "LocalAdapterLayer", "LayerName": "local-adapter"},
}
for r in t.get("Resources", {}).values():
layers = r.get("Properties", {}).get("Layers", [])
for i, l in enumerate(layers):
if "LambdaAdapterLayerX86" in str(l):
layers[i] = {"Ref": "LocalAdapterLayer"}
with open(".aws-sam/build/template.yaml", "w") as f:
yaml.dump(t, f)
PYEOF
- name: Start local API and verify
working-directory: examples/${{ matrix.example.name }}
run: |
# Pass the app's listening port via PORT (must differ from RIE's 8080).
# Start and verify in the same step so the backgrounded sam local
# stays attached to this shell while requests are served (splitting
# it across steps races the container warm-up and yields 500s).
echo '{"Parameters":{"PORT":"${{ matrix.example.port }}"}}' > /tmp/env.json
sam local start-api --port 3000 --warm-containers EAGER --container-env-vars /tmp/env.json &
echo "SAM_PID=$!" >> $GITHUB_ENV
"$GITHUB_WORKSPACE/.github/scripts/verify-http.sh" \
http://127.0.0.1:3000 \
"${{ matrix.example.path }}" \
200 \
"${{ matrix.example.expect_body }}"
- name: Stop local API
if: always()
run: kill $SAM_PID 2>/dev/null || true
# Response-streaming examples verified by building and running the real app,
# then asserting an exact status/body with verify-http.sh. These use a Lambda
# Function URL with RESPONSE_STREAM and have no API Gateway events, so
# sam local start-api (used by test-image/test-zip) cannot drive them.
#
# `kind` selects how the app is launched:
# image - build the app image (its Dockerfile COPYs the local adapter
# image built below) and run it with PORT=8000.
# zip-fasthtml - run `python app/main.py` (FastHTML's serve() binds 8000).
# AWS_REGION is set so boto3 / AnthropicBedrock clients construct without real
# creds; the verified routes only render UI and never call Bedrock.
test-stream:
needs: [build-layer]
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
example:
- { name: fasthtml-response-streaming, kind: image, path: /, expect_body: "Serverless Bedtime" }
- { name: fasthtml-response-streaming-zip, kind: zip-fasthtml, path: /, expect_body: "Click to stream" }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.12"
- uses: actions/download-artifact@v4
with:
name: layer-x86_64
path: /tmp/layer-x86_64
- name: Build local adapter image
run: |
# Build the local adapter under every tag the example Dockerfiles
# reference, so each example's `docker build` (COPY --from=...:<tag>)
# uses the locally compiled binary instead of pulling the released
# image. Deriving the tags from the Dockerfiles keeps CI in lockstep
# with them (no hardcoded version to forget at release time).
chmod +x /tmp/layer-x86_64/lambda-adapter
tags=$(grep -rhoE 'aws-lambda-adapter:[0-9A-Za-z._-]+' examples | sed 's/.*://' | sort -u)
echo "Building local adapter for tags: $tags"
for tag in $tags; do
printf 'FROM scratch\nCOPY --chmod=755 lambda-adapter /lambda-adapter\n' | \
docker build -t public.ecr.aws/awsguru/aws-lambda-adapter:$tag -f- /tmp/layer-x86_64
done
- name: Build and start app
working-directory: examples/${{ matrix.example.name }}
env:
KIND: ${{ matrix.example.kind }}
run: |
case "$KIND" in
image)
docker build -t stream-app app
docker run -d --name stream-app \
-e PORT=8000 -e AWS_REGION=us-east-1 -e AWS_DEFAULT_REGION=us-east-1 \
-p "8000:8000" stream-app
;;
zip-fasthtml)
pip install -r app/requirements.txt
PORT=8000 nohup python app/main.py > /tmp/app.log 2>&1 &
echo "APP_PID=$!" >> $GITHUB_ENV
;;
*)
echo "Unknown kind: $KIND"; exit 1 ;;
esac
- name: Verify
run: |
"$GITHUB_WORKSPACE/.github/scripts/verify-http.sh" \
http://127.0.0.1:8000 \
"${{ matrix.example.path }}" \
200 \
"${{ matrix.example.expect_body }}" \
|| { echo "--- app logs ---"; docker logs stream-app 2>/dev/null || cat /tmp/app.log 2>/dev/null || true; exit 1; }
- name: Stop app
if: always()
run: |
docker rm -f stream-app 2>/dev/null || true
kill $APP_PID 2>/dev/null || true