Skip to content

Deploy Amethyst API to AWS #325

Deploy Amethyst API to AWS

Deploy Amethyst API to AWS #325

Workflow file for this run

name: Deploy Amethyst API to AWS
on:
push:
branches:
- main
schedule:
# Runs at 06:00 UTC every day during MLB season (March–October)
- cron: '0 6 * 3-10 *'
workflow_dispatch:
inputs:
mode:
description: "Run mode: redeploy, restart, sync-players, or describe-env (dump App Runner config — read-only)"
type: choice
required: true
default: redeploy
options:
- redeploy
- restart
- sync-players
- describe-env
jobs:
sync-players:
runs-on: ubuntu-latest
# Run on the daily schedule or on manual `sync-players` dispatch.
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'sync-players')
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install
- name: Sync MLB Players
env:
MONGO_URI: ${{ secrets.MONGO_URI }}
run: pnpm sync-players -- --confirm-universe-write
# Optional: repo secret CI_FAILURE_WEBHOOK (Slack incoming webhook or Discord;
# payload sets both `text` and `content` so either accepts the message.)
- name: Notify on sync failure (optional webhook)
if: failure()
env:
WEBHOOK_URL: ${{ secrets.CI_FAILURE_WEBHOOK }}
GH_REPO: ${{ github.repository }}
GH_RUN: ${{ github.run_id }}
run: |
if [ -z "${WEBHOOK_URL}" ]; then
echo "No CI_FAILURE_WEBHOOK secret; skipping external notify."
exit 0
fi
msg="sync-players failed in ${GH_REPO} (run ${GH_RUN})"
payload=$(jq -n --arg text "$msg" --arg content "$msg" '{text: $text, content: $content}')
curl -fsS -X POST -H 'Content-Type: application/json' \
-d "$payload" "$WEBHOOK_URL"
describe-env:
runs-on: ubuntu-latest
# Read-only diagnostic. Dumps the App Runner service config (env vars,
# image identifier, observability config) so we can confirm what's
# actually running in production. AWS masks plaintext secret values
# ("RuntimeEnvironmentSecrets" returns ARNs only; plaintext
# "RuntimeEnvironmentVariables" come back verbatim — we redact
# passwords from MONGO_URI / REDIS_URL in the post-processing step).
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'describe-env'
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Describe service (redacted)
env:
APP_RUNNER_SERVICE_NAME: amethyst-standalone-api
run: |
set -euo pipefail
SERVICE_ARN=$(aws apprunner list-services \
--query "ServiceSummaryList[?ServiceName=='${APP_RUNNER_SERVICE_NAME}'].ServiceArn | [0]" \
--output text)
if [ -z "$SERVICE_ARN" ] || [ "$SERVICE_ARN" = "None" ]; then
echo "Error: App Runner service '${APP_RUNNER_SERVICE_NAME}' not found."
exit 1
fi
echo "Service ARN: $SERVICE_ARN"
echo
echo "==== service status / image identifier / observability ===="
aws apprunner describe-service --service-arn "$SERVICE_ARN" \
--query "{Status: Service.Status, ServiceUrl: Service.ServiceUrl, CreatedAt: Service.CreatedAt, UpdatedAt: Service.UpdatedAt, ImageIdentifier: Service.SourceConfiguration.ImageRepository.ImageIdentifier, ImageRepositoryType: Service.SourceConfiguration.ImageRepository.ImageRepositoryType, AutoDeploymentsEnabled: Service.SourceConfiguration.AutoDeploymentsEnabled, AuthenticationConfiguration: Service.SourceConfiguration.AuthenticationConfiguration, InstanceConfigCpu: Service.InstanceConfiguration.Cpu, InstanceConfigMemory: Service.InstanceConfiguration.Memory, AutoScalingConfigurationSummary: Service.AutoScalingConfigurationSummary, ObservabilityConfiguration: Service.ObservabilityConfiguration}" \
--output json
echo
echo "==== environment variables (passwords redacted) ===="
aws apprunner describe-service --service-arn "$SERVICE_ARN" \
--query "Service.SourceConfiguration.ImageRepository.ImageConfiguration.RuntimeEnvironmentVariables" \
--output json \
| jq 'to_entries
| map({key: .key,
value: (.value
| if type == "string"
then gsub("(?<a>://[^:]+:)[^@]+(?<b>@)"; "\(.a)***\(.b)")
else . end)})
| sort_by(.key)
| from_entries'
echo
echo "==== secret refs (ARNs only) ===="
aws apprunner describe-service --service-arn "$SERVICE_ARN" \
--query "Service.SourceConfiguration.ImageRepository.ImageConfiguration.RuntimeEnvironmentSecrets" \
--output json
echo
echo "==== latest two deployment operations ===="
aws apprunner list-operations --service-arn "$SERVICE_ARN" --max-results 2 \
--query "OperationSummaryList[].{Id: Id, Type: Type, Status: Status, StartedAt: StartedAt, EndedAt: EndedAt, TargetArn: TargetArn}" \
--output json
echo
echo "==== ECR image manifest for currently-pinned identifier (digest + pushedAt) ===="
IMG=$(aws apprunner describe-service --service-arn "$SERVICE_ARN" \
--query "Service.SourceConfiguration.ImageRepository.ImageIdentifier" --output text)
REPO=${IMG##*/}
REPO=${REPO%%:*}
TAG=${IMG##*:}
echo "ECR repository: $REPO tag: $TAG"
aws ecr describe-images --repository-name "$REPO" \
--image-ids imageTag="$TAG" \
--query "imageDetails[].{Digest: imageDigest, PushedAt: imagePushedAt, Size: imageSizeInBytes, Tags: imageTags}" \
--output json
restart:
runs-on: ubuntu-latest
# Hard cycle App Runner instances: pause -> wait -> resume. This is the
# only reliable way to drop in-process catalog caches on stuck instances
# when `start-deployment` doesn't roll them on its own (e.g. when the
# new image is a no-op vs. the running one). Triggered manually via
# `gh workflow run deploy.yml -f mode=restart`.
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'restart'
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Pause + resume App Runner service
env:
APP_RUNNER_SERVICE_NAME: amethyst-standalone-api
run: |
set -euo pipefail
SERVICE_ARN=$(aws apprunner list-services \
--query "ServiceSummaryList[?ServiceName=='${APP_RUNNER_SERVICE_NAME}'].ServiceArn | [0]" \
--output text)
if [ -z "$SERVICE_ARN" ] || [ "$SERVICE_ARN" = "None" ]; then
echo "Error: App Runner service '${APP_RUNNER_SERVICE_NAME}' not found."
exit 1
fi
echo "Service ARN: $SERVICE_ARN"
echo "Pausing service..."
aws apprunner pause-service --service-arn "$SERVICE_ARN" >/dev/null
for i in {1..20}; do
STATUS=$(aws apprunner describe-service --service-arn "$SERVICE_ARN" --query "Service.Status" --output text)
echo "[$i/20] status=$STATUS"
if [ "$STATUS" = "PAUSED" ]; then break; fi
sleep 15
done
if [ "$STATUS" != "PAUSED" ]; then
echo "Error: service did not reach PAUSED in time (last status: $STATUS)."
exit 1
fi
echo "Resuming service..."
aws apprunner resume-service --service-arn "$SERVICE_ARN" >/dev/null
for i in {1..20}; do
STATUS=$(aws apprunner describe-service --service-arn "$SERVICE_ARN" --query "Service.Status" --output text)
echo "[$i/20] status=$STATUS"
if [ "$STATUS" = "RUNNING" ]; then break; fi
sleep 15
done
if [ "$STATUS" != "RUNNING" ]; then
echo "Error: service did not return to RUNNING in time (last status: $STATUS)."
exit 1
fi
echo "Service is RUNNING again — fresh instances will warm against current Mongo."
deploy:
runs-on: ubuntu-latest
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'redeploy')
services:
mongo:
image: mongo:7
ports:
- 27017:27017
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install
- name: Test portal
run: pnpm --filter amethyst-portal exec vitest run
- name: Build API
run: pnpm build
- name: Check Types
run: pnpm check-types
- name: Lint
run: pnpm lint
- name: Lint portal
run: pnpm --filter amethyst-portal exec eslint src --max-warnings 0
- name: Test
env:
KEY_ISSUANCE_INTEGRATION_MONGO_URI: mongodb://127.0.0.1:27017/key_issue_ci
run: pnpm test
- name: Replay evaluator (checkpoint smoke)
run: pnpm replay-eval
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push API image to Amazon ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: amethyst-standalone-api
IMAGE_TAG: latest
run: |
docker build \
--build-arg BUILD_GIT_SHA="${{ github.sha }}" \
-t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
- name: Deploy to App Runner
env:
APP_RUNNER_SERVICE_NAME: amethyst-standalone-api
run: |
SERVICE_ARN=$(aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='${APP_RUNNER_SERVICE_NAME}'].ServiceArn | [0]" --output text)
if [ -z "$SERVICE_ARN" ] || [ "$SERVICE_ARN" = "None" ]; then
echo "Error: App Runner service '${APP_RUNNER_SERVICE_NAME}' not found."
exit 1
fi
echo "Found Service ARN: $SERVICE_ARN"
# Wait up to 5 minutes for any existing operations (like a previous push) to finish
for i in {1..10}; do
STATUS=$(aws apprunner describe-service --service-arn "$SERVICE_ARN" --query "Service.Status" --output text)
if [ "$STATUS" = "RUNNING" ]; then
echo "Service is RUNNING. Starting deployment..."
aws apprunner start-deployment --service-arn "$SERVICE_ARN"
exit 0
fi
echo "Service is currently $STATUS. Waiting 30s before retry ($i/10)..."
sleep 30
done
echo "Error: Service did not return to RUNNING state in time."
exit 1