# .github/workflows/aws-deploy.yml — generated by
# `autumn release init --target aws-ecs`
#
# Opt-in: this workflow only runs when you push a `v*` tag (or trigger it
# manually), and every credential comes from repo/environment secrets that
# don't exist until you add them — it never fires unauthenticated.
#
# Required repository secrets (Settings -> Secrets and variables -> Actions):
# AWS_ROLE_ARN — an IAM role with a trust policy for GitHub's OIDC
# provider (see aws-actions/configure-aws-credentials' OIDC guide),
# granted:
# - ecr:GetAuthorizationToken (account-wide — the API requires it) plus
# ecr:BatchCheckLayerAvailability/PutImage/InitiateLayerUpload/
# UploadLayerPart/CompleteLayerUpload/BatchGetImage/DescribeImages
# scoped to `terraform output ecr_repository_url`'s repository ARN
# — never account-wide `ecr:*`.
# - ecs:RegisterTaskDefinition/DescribeTaskDefinition/RunTask/
# DescribeTasks/StopTask/UpdateService/DescribeServices (the
# task-definition re-registration steps below call
# DescribeTaskDefinition before RegisterTaskDefinition — both are
# required, not just the latter; StopTask is used to kill a
# migration task that exceeds its polling budget).
# - iam:PassRole for `terraform output execution_role_arn` and
# `terraform output task_role_arn` (RunTask/RegisterTaskDefinition
# need to pass both roles to ECS).
# No long-lived AWS access keys are needed: `permissions.id-token:
# write` below plus aws-actions/configure-aws-credentials'
# role-to-assume input is enough for OIDC login.
#
# Required repository variables (same Settings page, "Variables" tab — not
# secret, just config the job needs), all `terraform output` values — never
# hand-typed:
# AWS_REGION — the `region` you deployed into.
# ECR_REPOSITORY_URL — output of `terraform output ecr_repository_url`.
# ECS_CLUSTER_NAME — output of `terraform output ecs_cluster_name`.
# ECS_SERVICE_NAME — output of `terraform output ecs_service_name`.
# APP_TASK_FAMILY — output of `terraform output app_task_family`.
# MIGRATE_TASK_FAMILY — output of `terraform output migrate_task_family`.
# ECS_TASKS_SECURITY_GROUP_ID — output of `terraform output ecs_tasks_security_group_id`.
# ECS_PRIVATE_SUBNET_IDS — the compact JSON array
# `terraform output -json private_subnet_ids | jq -c .` prints (e.g.
# `["subnet-0abc123","subnet-0def456"]`) — set verbatim, do NOT reformat
# into a comma-separated list; the migration step below embeds this
# value directly into an `aws ecs run-task --network-configuration`
# JSON payload as-is.
name: aws-deploy
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
# Two overlapping runs (e.g. two rapid tag pushes, or a tag push racing
# a manual dispatch) must not interleave: the older run's later
# `update-service` could execute after the newer one and roll
# production back, or leave stale code serving against a schema the
# newer run just migrated. One queue per repo, `cancel-in-progress:
# false` so a run is never killed mid-migration/mid-cutover — it waits
# its turn instead.
concurrency:
group: aws-deploy-${{ github.repository }}
cancel-in-progress: false
# Declaring `permissions` at all switches GITHUB_TOKEN from its default
# repo scopes to exactly this allow-list — anything not listed here is
# `none`. `actions: read` is required by the "Abort if a newer run"
# step's `gh api .../actions/workflows/.../runs` call below; without it
# that step 403s on every single run, after the image has already built
# and pushed, aborting before migration/deploy ever runs.
permissions:
contents: read
id-token: write
actions: read
steps:
- uses: actions/checkout@v4
# Must be unique per run, not just per ref: two workflow_dispatch runs
# on the same branch would otherwise compute the identical tag despite
# different commits. Re-pushing bytes under a tag the ECS service
# already has running isn't guaranteed to register as a deployable
# change on its own — this workflow always registers a fresh task
# definition revision regardless, but a stable, collision-free tag
# still matters for traceability in ECR. The commit SHA is always
# unique; keep the sanitized ref/tag as a human-readable prefix (a
# `v*` push tag or a workflow_dispatch branch name may legally
# contain characters Docker tags reject — not just "/" in branches
# like "feature/login", but e.g. "+" in a SemVer tag like
# "v1.2.3+build" — so it still needs sanitizing).
- name: Compute a unique, Docker-safe image tag
id: image_tag
run: |
SAFE_REF=$(echo -n "$GITHUB_REF_NAME" | tr -c 'A-Za-z0-9_.-' '-' | cut -c1-80)
# Docker's tag grammar requires the FIRST character to be a word
# character ([A-Za-z0-9_]) — "." and "-" are allowed elsewhere but
# not at position zero. A ref starting with e.g. "+hotfix" would
# otherwise sanitize to a leading "-", which `docker build` rejects.
SAFE_REF=$(echo -n "$SAFE_REF" | sed -E 's/^[.-]+//')
[ -z "$SAFE_REF" ] && SAFE_REF="build"
# GITHUB_SHA alone isn't enough: a workflow_dispatch re-run (or
# clicking "Re-run jobs") on the same ref reuses the identical
# commit, so a tag built only from ref+SHA collides with the
# previous run's tag despite this being a genuinely different
# build. GITHUB_RUN_ID is unique per trigger; GITHUB_RUN_ATTEMPT
# disambiguates re-runs of that same trigger.
echo "value=${SAFE_REF}-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Log in to ECR
uses: aws-actions/amazon-ecr-login@v2
# The Dockerfile declares AUTUMN_BUILD_* ARGs (see its own header
# comment) that build.rs bakes into the binary for /actuator/info's
# git provenance — but ARGs default to empty unless passed at
# `docker build` time, and .dockerignore excludes .git from the build
# context, so build.rs has no other way to discover them. Without
# these, every image this workflow builds reports null git info.
- name: Build release image
run: |
docker build \
--build-arg AUTUMN_BUILD_GIT_SHA="$GITHUB_SHA" \
--build-arg AUTUMN_BUILD_GIT_SHA_SHORT="${GITHUB_SHA:0:8}" \
--build-arg AUTUMN_BUILD_GIT_BRANCH="$GITHUB_REF_NAME" \
--build-arg AUTUMN_BUILD_GIT_DIRTY=false \
--build-arg AUTUMN_BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t "$ECR_REPOSITORY_URL:$IMAGE_TAG" .
env:
ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
- name: Push to ECR
run: docker push "$ECR_REPOSITORY_URL:$IMAGE_TAG"
env:
ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
# The concurrency group above prevents two runs from executing at the
# same time, but GitHub does not document strict FIFO ordering for
# which queued run goes next. A same-ref check isn't enough either:
# two DIFFERENT immutable tags (e.g. v1 then v2) each trigger their
# own run against their own never-moving $GITHUB_REF, so checking
# only "has my ref moved" is blind to a newer release landing under a
# different tag. `run_number` is GitHub's own counter, monotonically
# increasing in trigger order for this workflow file regardless of
# which ref triggered it — so check right before the point of no
# return (migrating/deploying) whether any OTHER run of this workflow
# with a higher run_number exists AT ALL, regardless of its status or
# conclusion. Filtering to only "still active" or "completed
# successfully" runs is NOT enough: a newer run can migrate (the
# actual point of no return — the schema is advanced) and then fail
# on the deploy step after it, reporting an overall conclusion of
# "failure" — a status-filtered check would miss it entirely and let
# this older run migrate/deploy right over a release whose schema
# change already went out.
- name: Abort if a newer run of this workflow exists
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
NEWER=$(gh api "repos/${{ github.repository }}/actions/workflows/aws-deploy.yml/runs?per_page=50" \
--jq "[.workflow_runs[] | select(.run_number > ${{ github.run_number }})] | length")
if [ "$NEWER" -gt 0 ]; then
echo "::error::a newer run of this workflow (run_number > ${{ github.run_number }}) exists — this run is superseded, aborting before migrating/deploying out of order"
exit 1
fi
# Task definitions are immutable per revision — the running image is
# changed by registering a NEW revision, not by editing the current
# one. `describe-task-definition` fetches the current (Terraform- or
# previously-CI-registered) revision as a template, `jq` swaps in the
# new image and strips the read-only fields AWS rejects on
# `register-task-definition` (registeredAt/registeredBy/status/etc,
# plus the response's own top-level `taskDefinition` wrapper),
# leaving every other setting (env, secrets, logging, cpu/memory)
# exactly as Terraform declared it.
- name: Register a new "app" task definition revision
id: register_app
run: |
set -euo pipefail
# Also strips entryPoint/command: Terraform's bootstrap revision
# (main.tf) overrides both to make the placeholder nginx image
# satisfy the ALB health check before any real image exists (see
# its own comment) — describe-task-definition would otherwise
# carry that override forward onto the REAL image, which has no
# nginx and runs as an unprivileged user, so the container would
# exit immediately instead of falling through to its own
# Dockerfile ENTRYPOINT/CMD (tini + the compiled binary).
NEW_DEF=$(aws ecs describe-task-definition --task-definition "$APP_TASK_FAMILY" \
--query 'taskDefinition' | \
jq --arg IMAGE "$ECR_REPOSITORY_URL:$IMAGE_TAG" \
--argjson SECRETS "$(aws ecs describe-task-definition --task-definition "$MIGRATE_TASK_FAMILY" --query 'taskDefinition.containerDefinitions[0].secrets' --output json)" \
'.containerDefinitions[0].image = $IMAGE |
.containerDefinitions[0].secrets = $SECRETS |
del(.containerDefinitions[0].entryPoint, .containerDefinitions[0].command) |
del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
.compatibilities, .registeredAt, .registeredBy, .deregisteredAt)')
NEW_ARN=$(echo "$NEW_DEF" | aws ecs register-task-definition --cli-input-json file:///dev/stdin \
--query 'taskDefinition.taskDefinitionArn' --output text)
echo "arn=$NEW_ARN" >> "$GITHUB_OUTPUT"
env:
APP_TASK_FAMILY: ${{ vars.APP_TASK_FAMILY }}
MIGRATE_TASK_FAMILY: ${{ vars.MIGRATE_TASK_FAMILY }}
ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
- name: Register a new "migrate" task definition revision
id: register_migrate
run: |
set -euo pipefail
NEW_DEF=$(aws ecs describe-task-definition --task-definition "$MIGRATE_TASK_FAMILY" \
--query 'taskDefinition' | \
jq --arg IMAGE "$ECR_REPOSITORY_URL:$IMAGE_TAG" \
'.containerDefinitions[0].image = $IMAGE |
del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
.compatibilities, .registeredAt, .registeredBy, .deregisteredAt)')
NEW_ARN=$(echo "$NEW_DEF" | aws ecs register-task-definition --cli-input-json file:///dev/stdin \
--query 'taskDefinition.taskDefinitionArn' --output text)
echo "arn=$NEW_ARN" >> "$GITHUB_OUTPUT"
env:
MIGRATE_TASK_FAMILY: ${{ vars.MIGRATE_TASK_FAMILY }}
ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
# Runs `autumn migrate` to completion in a one-shot Fargate task
# BEFORE the app service is updated, so new app tasks never start
# against an unmigrated schema (production config disables startup
# migrations — see docs/guide/deployment.md). `run-task` only starts
# the task and returns immediately; it does NOT wait for it to
# finish, so the poll loop below is required. Aborts the workflow —
# never reaching the deploy step — if the migration task fails or a
# container-level Fargate fault (an infra-level `stoppedReason`, not
# an application exit code) prevents it from running at all.
- name: Run database migrations
run: |
set -euo pipefail
# ECS_PRIVATE_SUBNET_IDS is already the compact JSON array
# `terraform output -json private_subnet_ids | jq -c .` produces
# (see the header comment) — `jq -c .` here just re-compacts it
# defensively (e.g. if it was pasted with pretty-printed
# whitespace/newlines) and fails loudly on genuinely malformed
# input rather than silently building a broken
# --network-configuration payload.
SUBNETS=$(echo "$ECS_PRIVATE_SUBNET_IDS" | jq -c .)
TASK_ARN=$(aws ecs run-task \
--cluster "$ECS_CLUSTER_NAME" \
--task-definition "$MIGRATE_TASK_DEF_ARN" \
--launch-type FARGATE \
--network-configuration "{\"awsvpcConfiguration\":{\"subnets\":$SUBNETS,\"securityGroups\":[\"$ECS_TASKS_SECURITY_GROUP_ID\"],\"assignPublicIp\":\"DISABLED\"}}" \
--query 'tasks[0].taskArn' --output text)
if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then
echo "::error::failed to start the migration task" >&2
exit 1
fi
# Budget: 10 minutes (60 x 10s), matching the ECS default
# stopTimeout headroom. Polling any shorter risks reporting
# "timed out" on a migration that's still validly running.
for _ in $(seq 1 60); do
STATUS=$(aws ecs describe-tasks --cluster "$ECS_CLUSTER_NAME" --tasks "$TASK_ARN" \
--query 'tasks[0].lastStatus' --output text)
if [ "$STATUS" = "STOPPED" ]; then
EXIT_CODE=$(aws ecs describe-tasks --cluster "$ECS_CLUSTER_NAME" --tasks "$TASK_ARN" \
--query 'tasks[0].containers[0].exitCode' --output text)
STOPPED_REASON=$(aws ecs describe-tasks --cluster "$ECS_CLUSTER_NAME" --tasks "$TASK_ARN" \
--query 'tasks[0].stoppedReason' --output text)
if [ "$EXIT_CODE" = "0" ]; then
exit 0
fi
echo "::error::migration task stopped (exitCode=$EXIT_CODE, reason=$STOPPED_REASON)" >&2
exit 1
fi
sleep 10
done
# ECS tasks have no runtime limit of their own — stopTimeout only
# bounds the grace period AFTER a stop is requested. Without
# explicitly stopping it here, a migration that blows the polling
# budget keeps running in the background even after this job
# gives up; the concurrency group is released the moment this job
# fails, so a LATER deploy could start a second migration task
# while this one is still mutating the schema.
echo "::error::migration task did not finish within the time budget — stopping it" >&2
# Redirects stdout rather than passing an --output flag: the AWS
# CLI's valid --output values are json/yaml/yaml-stream/text/
# table/off (there is no "none"), and even "off" may not exist on
# older CLI builds — a plain redirect discards the response
# regardless of CLI version.
aws ecs stop-task --cluster "$ECS_CLUSTER_NAME" --task "$TASK_ARN" \
--reason "aws-deploy.yml: migration exceeded its polling budget" >/dev/null
aws ecs wait tasks-stopped --cluster "$ECS_CLUSTER_NAME" --tasks "$TASK_ARN"
exit 1
env:
ECS_CLUSTER_NAME: ${{ vars.ECS_CLUSTER_NAME }}
MIGRATE_TASK_DEF_ARN: ${{ steps.register_migrate.outputs.arn }}
ECS_PRIVATE_SUBNET_IDS: ${{ vars.ECS_PRIVATE_SUBNET_IDS }}
ECS_TASKS_SECURITY_GROUP_ID: ${{ vars.ECS_TASKS_SECURITY_GROUP_ID }}
- name: Deploy new image to the ECS service
run: |
set -euo pipefail
aws ecs update-service \
--cluster "$ECS_CLUSTER_NAME" \
--service "$ECS_SERVICE_NAME" \
--task-definition "$APP_TASK_DEF_ARN" \
--force-new-deployment >/dev/null
aws ecs wait services-stable \
--cluster "$ECS_CLUSTER_NAME" \
--services "$ECS_SERVICE_NAME"
# `services-stable`'s predicate only requires ONE deployment to
# have runningCount == desiredCount — if the new revision failed
# its health checks, the deployment circuit breaker
# (aws_ecs_service.deployment_circuit_breaker, main.tf) rolls the
# service back to the PREVIOUS revision automatically, and that
# older deployment satisfies the waiter's predicate just as well.
# Without this check, this step would report success even though
# $APP_TASK_DEF_ARN was never actually running.
DEPLOYED_TASK_DEF=$(aws ecs describe-services \
--cluster "$ECS_CLUSTER_NAME" --services "$ECS_SERVICE_NAME" \
--query "services[0].deployments[?status=='PRIMARY'].taskDefinition | [0]" \
--output text)
if [ "$DEPLOYED_TASK_DEF" != "$APP_TASK_DEF_ARN" ]; then
echo "::error::deployment rolled back — the PRIMARY deployment is running $DEPLOYED_TASK_DEF, not the requested $APP_TASK_DEF_ARN (the circuit breaker likely reverted a failing health check)" >&2
exit 1
fi
env:
ECS_CLUSTER_NAME: ${{ vars.ECS_CLUSTER_NAME }}
ECS_SERVICE_NAME: ${{ vars.ECS_SERVICE_NAME }}
APP_TASK_DEF_ARN: ${{ steps.register_app.outputs.arn }}