autumn-cli 0.7.0

CLI tool for the Autumn web framework
# .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 }}