Skip to content

Deploy to Amazon ECS #14888

Deploy to Amazon ECS

Deploy to Amazon ECS #14888

Workflow file for this run

name: Deploy to Amazon ECS
env:
aws_region: eu-west-2
webservers_cluster_name: webservers
sidekiq_cluster_name: sidekiq
website_task_definition_name: website
website_service_name: website
api_task_definition_name: api
api_service_name: api
anycable_task_definition_name: anycable
anycable_service_name: anycable
sidekiq_task_definition_name: sidekiq
sidekiq_service_name: sidekiq
ecr_repository_rails: "webserver-rails"
dockerfile_rails: "docker/rails.Dockerfile"
ecr_repository_nginx: "webserver-nginx"
dockerfile_nginx: "docker/nginx.Dockerfile"
ecr_repository_anycable_go: "anycable-go"
dockerfile_anycable_go: "docker/anycable-go.Dockerfile"
# TODO: (Required) Move this to be a secret
s3_bucket: "exercism-v3-assets"
# The only config the Rails image build needs: both are compiled into the
# JS, which every browser downloads. The build boots the app without AWS
# (see config/image_build.rb), so it has no route to any other setting.
website_assets_host: "https://assets.exercism.org"
sentry_js_dsn: ${{ vars.SENTRY_JS_DSN }}
on:
push:
branches: [main]
workflow_dispatch:
schedule:
- cron: 0 */2 * * *
# Nothing in this job reads or writes the repository through the Actions
# token: AWS and Cloudflare are reached with their own credentials.
permissions:
contents: read
# A deploy rolls four ECS services and then uploads the Worker, so two runs
# overlapping would race each other onto the cluster. Runs queue instead of
# cancelling: a half-finished rollout is worse than a slow one.
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
# Forks of this repository get the workflow file along with everything
# else, and org secrets are not shared with them. This keeps the job to
# the one repository that is meant to deploy.
if: github.repository == 'exercism/website'
name: Upload images to ECR, assets to S3, and definition to ECS
runs-on: ubuntu-latest
steps:
###############
###############
# Setup Steps #
###############
###############
- name: Checkout
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
# The Rails image, its build stage and the BuildKit cache together need
# more than a hosted runner has spare, so the preinstalled toolchains we
# never touch are deleted first. This buys about 20GB in under a minute.
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/usr/local/.ghcup /opt/hostedtoolcache/CodeQL
df -h /
# The gha cache backend below is unavailable to the default docker
# driver, so the builds run on a docker-container builder.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e1e17a757e536f70e52b5a12b2e8d1d1c60e04ef
with:
aws-access-key-id: ${{ secrets.AWS_DEPLOY_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_DEPLOY_SECRET_ACCESS_KEY }}
aws-region: ${{ env.aws_region }}
- name: Login to Amazon ECR
id: login_to_ecr
uses: aws-actions/amazon-ecr-login@2f9f10ea3fa2eed41ac443fee8bfbd059af2d0a4
#########################
#########################
# Build the Rails image #
#########################
#########################
# The bundler version is computed from the checkout, so it is resolved
# here and handed to both of the builds below unchanged: the assets
# export only reuses the build stage's cache if its arguments match the
# push build's byte for byte.
- name: Resolve image tags and build arguments
id: images
env:
ECR_REGISTRY: ${{ steps.login_to_ecr.outputs.registry }}
run: |
{
echo "rails=$ECR_REGISTRY/${{ env.ecr_repository_rails }}:${{ github.sha }}"
echo "nginx=$ECR_REGISTRY/${{ env.ecr_repository_nginx }}:${{ github.sha }}"
echo "bundler_version=$(awk '/BUNDLED WITH/ {getline;$1=$1;print}' Gemfile.lock)"
} >> "$GITHUB_OUTPUT"
# mode=max so the build stage is cached too. Everything slow (bundle
# install, yarn install, assets:precompile) happens there, and a cache of
# the shipped image alone would still have to rebuild all of it.
#
# provenance is off so ECR keeps receiving a plain image manifest, which
# is what the task definitions below point at.
#
# This repository is public, and a pull request from a fork can restore
# caches written on main. The build takes no secrets and no AWS
# credentials, so these layers hold nothing beyond the repository and
# the two public values above.
- name: Build and Push Rails Docker
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: .
file: ${{ env.dockerfile_rails }}
target: runtime
push: true
provenance: false
tags: |
${{ steps.images.outputs.rails }}
${{ steps.login_to_ecr.outputs.registry }}/${{ env.ecr_repository_rails }}:latest
build-args: |
BUNDLER_VERSION=${{ steps.images.outputs.bundler_version }}
WEBSITE_ASSETS_HOST=${{ env.website_assets_host }}
SENTRY_JS_DSN=${{ env.sentry_js_dsn }}
cache-from: type=gha,scope=rails
cache-to: type=gha,mode=max,scope=rails,timeout=30m
##################################
##################################
# Extact and upload assets to s3 #
##################################
##################################
# The build stage is still in the builder's cache from the step above, so
# this resolves in seconds and writes out only the compiled assets.
- name: Copy assets from Dockerfile
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: .
file: ${{ env.dockerfile_rails }}
target: assets
outputs: type=local,dest=assets
build-args: |
BUNDLER_VERSION=${{ steps.images.outputs.bundler_version }}
WEBSITE_ASSETS_HOST=${{ env.website_assets_host }}
SENTRY_JS_DSN=${{ env.sentry_js_dsn }}
cache-from: type=gha,scope=rails
- name: Upload assets to s3
run: |
aws s3 sync assets s3://${{ env.s3_bucket }}/assets --acl public-read --no-progress --cache-control max-age=31536000
#########################
#########################
# Build the Nginx image #
#########################
#########################
# No layer cache: this is a base image and one COPY, so restoring a cache
# would cost more than the build it saves.
- name: Build and Push Nginx Docker
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: .
file: ${{ env.dockerfile_nginx }}
push: true
provenance: false
tags: |
${{ steps.images.outputs.nginx }}
${{ steps.login_to_ecr.outputs.registry }}/${{ env.ecr_repository_nginx }}:latest
##################################
##################################
# Update website Task definition #
##################################
##################################
# We get the existing task definition to update
# Note that this is passed into the first update action
# but then chained from action to action for the others.
- name: Download website task definition
run: |
aws ecs describe-task-definition --task-definition ${{ env.website_task_definition_name }} --query taskDefinition > website-task-definition.json
- name: Update website | puma container
id: update_definition_for_website_puma
uses: aws-actions/amazon-ecs-render-task-definition@61b0c00c3743b70987a73a1faf577f0d167d1574
with:
task-definition: website-task-definition.json
container-name: puma
image: "${{ steps.images.outputs.rails }}"
- name: Update website | nginx container
id: update_definition_for_website_nginx
uses: aws-actions/amazon-ecs-render-task-definition@61b0c00c3743b70987a73a1faf577f0d167d1574
with:
task-definition: ${{ steps.update_definition_for_website_puma.outputs.task-definition }}
container-name: nginx
image: "${{ steps.images.outputs.nginx }}"
# Push the new definition up to ECS and deploy the new service from it
- name: Deploy Website
uses: aws-actions/amazon-ecs-deploy-task-definition@df9643053eda01f169e64a0e60233aacca83799a
with:
task-definition: ${{ steps.update_definition_for_website_nginx.outputs.task-definition }}
service: ${{ env.website_service_name }}
cluster: ${{ env.webservers_cluster_name }}
wait-for-service-stability: false
##############################
##############################
# Update api Task definition #
##############################
##############################
# We get the existing task definition to update
# Note that this is passed into the first update action
# but then chained from action to action for the others.
- name: Download api task definition
run: |
aws ecs describe-task-definition --task-definition ${{ env.api_task_definition_name }} --query taskDefinition > api-task-definition.json
- name: Update api | puma container
id: update_definition_for_api_puma
uses: aws-actions/amazon-ecs-render-task-definition@61b0c00c3743b70987a73a1faf577f0d167d1574
with:
task-definition: api-task-definition.json
container-name: puma
image: "${{ steps.images.outputs.rails }}"
- name: Update api | nginx container
id: update_definition_for_api_nginx
uses: aws-actions/amazon-ecs-render-task-definition@61b0c00c3743b70987a73a1faf577f0d167d1574
with:
task-definition: ${{ steps.update_definition_for_api_puma.outputs.task-definition }}
container-name: nginx
image: "${{ steps.images.outputs.nginx }}"
# Push the new definition up to ECS and deploy the new service from it
- name: Deploy API
uses: aws-actions/amazon-ecs-deploy-task-definition@df9643053eda01f169e64a0e60233aacca83799a
with:
task-definition: ${{ steps.update_definition_for_api_nginx.outputs.task-definition }}
service: ${{ env.api_service_name }}
cluster: ${{ env.webservers_cluster_name }}
wait-for-service-stability: false
##################################
##################################
# Update anycable Task definition #
##################################
##################################
# We get the existing task definition to update
# Note that this is passed into the first update action
# but then chained from action to action for the others.
- name: Download anycable task definition
run: |
aws ecs describe-task-definition --task-definition ${{ env.anycable_task_definition_name }} --query taskDefinition > anycable-task-definition.json
- name: Update anycable | anycable_ruby container
id: update_definition_for_anycable_ruby
uses: aws-actions/amazon-ecs-render-task-definition@61b0c00c3743b70987a73a1faf577f0d167d1574
with:
task-definition: anycable-task-definition.json
container-name: anycable_ruby
image: "${{ steps.images.outputs.rails }}"
- name: Update anycable | anycable_go container
id: update_definition_for_anycable_go
uses: aws-actions/amazon-ecs-render-task-definition@61b0c00c3743b70987a73a1faf577f0d167d1574
with:
task-definition: ${{ steps.update_definition_for_anycable_ruby.outputs.task-definition }}
container-name: anycable_go
image: "${{ steps.login_to_ecr.outputs.registry}}/anycable-go-pro:latest"
- name: Update anycable | nginx container
id: update_definition_for_anycable_nginx
uses: aws-actions/amazon-ecs-render-task-definition@61b0c00c3743b70987a73a1faf577f0d167d1574
with:
task-definition: ${{ steps.update_definition_for_anycable_go.outputs.task-definition }}
container-name: nginx
image: "${{ steps.images.outputs.nginx }}"
# Push the new definition up to ECS and deploy the new service from it
- name: Deploy Anycable
uses: aws-actions/amazon-ecs-deploy-task-definition@df9643053eda01f169e64a0e60233aacca83799a
with:
task-definition: ${{ steps.update_definition_for_anycable_nginx.outputs.task-definition }}
service: ${{ env.anycable_service_name }}
cluster: ${{ env.webservers_cluster_name }}
wait-for-service-stability: false
##################################
##################################
# Update Sidekiq Task definition #
##################################
##################################
# We get the existing task definition to update
# Note that this is passed into the first update action
# but then chained from action to action for the others.
- name: Download sidekiq task definition
run: |
aws ecs describe-task-definition --task-definition ${{ env.sidekiq_task_definition_name }} --query taskDefinition > sidekiq-task-definition.json
# Set puma to rails
- name: Update the sidekiq definition
id: update_definition_for_sidekiq
uses: aws-actions/amazon-ecs-render-task-definition@61b0c00c3743b70987a73a1faf577f0d167d1574
with:
task-definition: sidekiq-task-definition.json
container-name: sidekiq
image: "${{ steps.images.outputs.rails }}"
# Push the new definition up to ECS and deploy a new service from it.
- name: Deploy Sidekiq
uses: aws-actions/amazon-ecs-deploy-task-definition@df9643053eda01f169e64a0e60233aacca83799a
with:
# Ensure this is the final step in the daisy-chain above
task-definition: ${{ steps.update_definition_for_sidekiq.outputs.task-definition }}
service: ${{ env.sidekiq_service_name }}
cluster: ${{ env.sidekiq_cluster_name }}
wait-for-service-stability: true
################################
################################
# Deploy the Cloudflare Worker #
################################
################################
# Last in the job, so the Worker only starts sending visitors to a locale
# prefix once the web servers above have rolled out and can serve it. It
# reads config/i18n.json, the same file Rails reads, so both go out from
# one commit. Routes belong to exercism/terraform; this uploads the script.
- name: Deploy locale-redirect Worker
working-directory: cloudflare/locale-redirect
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: npx --yes wrangler@4.136.0 deploy