# .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:
env:
# The posture manifest's path from the repository root. A workspace member
# sets `<member dir>/security-posture.json`: `hashFiles` and `subject-path`
# ignore `working-directory`.
POSTURE_MANIFEST: security-posture.json
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
# Publish a build-provenance attestation for the pushed image and an
# SBOM attestation for the inventory baked into it (issue #1615). These
# are keyless Sigstore signatures: `id-token: write` above supplies the
# OIDC identity, so there is no signing key to store or rotate. See
# docs/guide/supply-chain.md for the `gh attestation verify` command
# your users run against them.
attestations: write
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 }}
# ── Supply chain (issue #1615) ──────────────────────────────────────
# Deliberately the LAST steps in the job. The image is already in the
# registry by the time they run, so attesting first would buy nothing
# while letting a Sigstore hiccup — or a repository whose plan does not
# include artifact attestations — block a production deploy that is
# otherwise ready. `continue-on-error` keeps that a visible warning
# rather than a failed deploy of code that shipped fine.
#
# Both attestations bind the image DIGEST, never its tag: a tag can be
# re-pointed at different bytes later, a digest cannot.
- name: Resolve the pushed image digest
id: image_digest
continue-on-error: true
run: |
set -euo pipefail
# Match on the repository we actually pushed to. `RepoDigests` is a
# property of the image ID and can hold entries for several
# repositories; taking element 0 by position would attest whichever
# one the daemon happened to record first. `|| true` keeps the
# explicit diagnostic below reachable instead of dying inside the
# command substitution.
DIGEST=$(docker inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' \
"$ECR_REPOSITORY_URL:$IMAGE_TAG" 2>/dev/null | grep "^$ECR_REPOSITORY_URL@" | head -n1 | cut -d@ -f2 || true)
if [ -z "$DIGEST" ]; then
echo "::warning::could not resolve the digest of the image just pushed; skipping attestation"
exit 1
fi
echo "value=$DIGEST" >> "$GITHUB_OUTPUT"
env:
ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
- name: Attest build provenance for the image
if: steps.image_digest.outcome == 'success'
continue-on-error: true
uses: actions/attest-build-provenance@v3
with:
subject-name: ${{ vars.ECR_REPOSITORY_URL }}
subject-digest: ${{ steps.image_digest.outputs.value }}
{{sbom_steps_begin}}
# The image ships a CycloneDX SBOM at a fixed path (see the Dockerfile's
# `autumn sbom` step). Attest it against the same digest so "what is in
# this image" is verifiable without taking the image's word for it.
- name: Extract the SBOM baked into the image
id: image_sbom
if: steps.image_digest.outcome == 'success'
continue-on-error: true
run: |
set -euo pipefail
CID=$(docker create "$ECR_REPOSITORY_URL:$IMAGE_TAG")
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
docker cp "$CID:/usr/share/autumn/sbom.cdx.json" sbom.cdx.json
env:
ECR_REPOSITORY_URL: ${{ vars.ECR_REPOSITORY_URL }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
- name: Attest the image SBOM
if: steps.image_sbom.outcome == 'success'
continue-on-error: true
uses: actions/attest-sbom@v3
with:
subject-name: ${{ vars.ECR_REPOSITORY_URL }}
subject-digest: ${{ steps.image_digest.outputs.value }}
sbom-path: sbom.cdx.json
{{sbom_steps_end}}
# ── Security posture manifest (issue #1624) ─────────────────────────
# Sign the posture manifest this release ships — the same bytes the
# posture gate diffed and a reviewer acknowledged on the pull request.
# Attested as a file rather than baked into the image: the manifest is a
# property of the *source revision*, and attesting the committed copy is
# what lets a deploy prove the running app's declared security surface is
# the one that was reviewed.
#
# At deploy time, one command proves both halves:
#
# autumn routes posture verify --manifest security-posture.json \
# --expect-digest <digest recorded on the PR> --repo <owner>/<repo>
#
# See docs/guide/posture-gate.md. `continue-on-error` for the same reason
# as the attestations above: a Sigstore hiccup is a visible warning, not a
# blocked deploy of code that shipped fine.
- name: Attest the security posture manifest
if: hashFiles(env.POSTURE_MANIFEST) != ''
continue-on-error: true
uses: actions/attest-build-provenance@v3
with:
subject-path: ${{ env.POSTURE_MANIFEST }}
# Not a failure: an app that never adopted the posture gate must deploy.
# An app that did adopt it, with the wrong path, needs this message.
- name: Warn when the security posture manifest is missing
if: hashFiles(env.POSTURE_MANIFEST) == ''
run: echo "::warning::${POSTURE_MANIFEST} not found, so this release has no posture attestation. Ignore this if the app has not adopted the posture gate. If it has, set POSTURE_MANIFEST to the manifest path from the repository root."