From 0ba0b77976c2e6a1a41012db18551f1524ffdda8 Mon Sep 17 00:00:00 2001 From: Montgomery Scott Date: Sat, 21 Mar 2026 11:14:54 -0700 Subject: [PATCH] feat: add update-aws-app-runner composite action Adds a new composite action that triggers a manual deployment on an AWS App Runner service via the AWS CLI, with optional polling until the service reaches RUNNING status (15s interval, 10min timeout). Co-Authored-By: Claude Sonnet 4.6 --- .../actions/update-aws-app-runner/README.md | 46 ++++ .../actions/update-aws-app-runner/action.yml | 56 +++++ .../update-aws-app-runner/project.json | 4 + .../scripts/update_app_runner.sh | 86 +++++++ .github/workflows/code-quality.yml | 6 + .../test_update_app_runner.bats | 222 ++++++++++++++++++ 6 files changed, 420 insertions(+) create mode 100644 .github/actions/update-aws-app-runner/README.md create mode 100644 .github/actions/update-aws-app-runner/action.yml create mode 100644 .github/actions/update-aws-app-runner/project.json create mode 100644 .github/actions/update-aws-app-runner/scripts/update_app_runner.sh create mode 100644 tests/unit/update-aws-app-runner/test_update_app_runner.bats diff --git a/.github/actions/update-aws-app-runner/README.md b/.github/actions/update-aws-app-runner/README.md new file mode 100644 index 0000000..b0aaf95 --- /dev/null +++ b/.github/actions/update-aws-app-runner/README.md @@ -0,0 +1,46 @@ +# Update AWS App Runner GitHub Action + +## Overview + +This GitHub action deploys a new image to an AWS App Runner service by triggering a manual deployment via the AWS CLI. + +It starts the deployment and optionally waits for the service to reach a `RUNNING` state, polling every 15 seconds with a 10-minute timeout. + +## Inputs + +- `service_arn` - Required. The ARN of the App Runner service to deploy. +- `wait_for_deployment` - Optional. Whether to wait for the deployment to complete. Defaults to `'true'`. +Polls every 15 seconds and times out after 10 minutes. +- `aws_access_key_id` - Optional. AWS access key ID for credentials. +If this is NOT provided, the action will NOT set up AWS credentials; it is assumed they are already set up. +- `aws_secret_access_key` - Optional. AWS secret access key for credentials. +If this is NOT provided, the action will NOT set up AWS credentials; it is assumed they are already set up. +- `aws_default_region` - Optional. AWS default region for credentials. +If this is NOT provided, the action will NOT set up AWS credentials; it is assumed they are already set up. + +## Example Usage + +```yml +uses: generalui/github-workflow-accelerators/.github/actions/update-aws-app-runner@1.0.0-update-aws-app-runner +with: + service_arn: arn:aws:apprunner:us-east-1:123456789012:service/my-service/abc123def456 + wait_for_deployment: 'true' +``` + +This will trigger a deployment on the App Runner service and wait for it to reach a `RUNNING` state. + +## How it Works + +The action runs the following steps: + +1. Checks if AWS credentials need to be configured +1. Configures AWS credentials if provided +1. Calls `aws apprunner start-deployment` with the provided service ARN +1. If `wait_for_deployment` is `'true'`, polls `aws apprunner describe-service` every 15 seconds until the service status is `RUNNING` +1. Exits non-zero if the deployment fails or the 10-minute timeout is reached + +## Notes + +- Requires AWS credentials configured either via inputs or already present in the environment +- Requires the AWS CLI and `jq` to be installed on the runner +- The deploying IAM principal must have `apprunner:StartDeployment` and `apprunner:DescribeService` permissions diff --git a/.github/actions/update-aws-app-runner/action.yml b/.github/actions/update-aws-app-runner/action.yml new file mode 100644 index 0000000..0d6f7be --- /dev/null +++ b/.github/actions/update-aws-app-runner/action.yml @@ -0,0 +1,56 @@ +name: Update App Runner + +description: Deploy a new image to an AWS App Runner service by triggering a manual deployment + +inputs: + aws_access_key_id: + description: The AWS access key ID to use. If this is NOT provided, the action will NOT set up AWS credentials; it is assumed they are already set up. + required: false + default: '' + aws_secret_access_key: + description: The AWS secret access key to use. If this is NOT provided, the action will NOT set up AWS credentials; it is assumed they are already set up. + required: false + default: '' + aws_default_region: + description: The AWS region to use. If this is NOT provided, the action will NOT set up AWS credentials; it is assumed they are already set up. + required: false + default: '' + service_arn: + description: The ARN of the App Runner service to deploy. + required: true + wait_for_deployment: + description: Whether to wait for the deployment to complete (polls every 15s, times out after 10 minutes). + required: false + default: 'true' + +runs: + using: composite + steps: + - name: Should configure AWS + run: | + configure=true + if [[ -z "${{ inputs.aws_access_key_id }}" || -z "${{ inputs.aws_secret_access_key }}" || -z "${{ inputs.aws_default_region }}" ]]; then + configure=false + fi + echo "configure=${configure}" >> $GITHUB_OUTPUT + + echo "::group::Should Configure AWS Credentials" + echo 'configure: '${configure} + echo "::endgroup::" + shell: bash + id: should_configure_aws + + - name: Configure AWS credentials + if: steps.should_configure_aws.outputs.configure == 'true' + uses: generalui/github-workflow-accelerators/.github/actions/configure-aws@1.0.0-configure-aws + with: + aws_access_key_id: ${{ inputs.aws_access_key_id }} + aws_secret_access_key: ${{ inputs.aws_secret_access_key }} + aws_default_region: ${{ inputs.aws_default_region }} + + - name: Update App Runner + env: + SERVICE_ARN: ${{ inputs.service_arn }} + WAIT_FOR_DEPLOYMENT: ${{ inputs.wait_for_deployment }} + run: ${{ github.action_path }}/scripts/update_app_runner.sh + shell: bash diff --git a/.github/actions/update-aws-app-runner/project.json b/.github/actions/update-aws-app-runner/project.json new file mode 100644 index 0000000..c82138d --- /dev/null +++ b/.github/actions/update-aws-app-runner/project.json @@ -0,0 +1,4 @@ +{ + "name": "update-aws-app-runner", + "version": "1.0.0" +} diff --git a/.github/actions/update-aws-app-runner/scripts/update_app_runner.sh b/.github/actions/update-aws-app-runner/scripts/update_app_runner.sh new file mode 100644 index 0000000..c0ba42a --- /dev/null +++ b/.github/actions/update-aws-app-runner/scripts/update_app_runner.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +update_app_runner() { + # Defined some useful colors for echo outputs. + # Use blue for informational. + local blue="\033[1;34m" + # Use green for success informational. + local green="\033[1;32m" + # Use red for error informational and extreme actions. + local red="\033[1;31m" + # No Color (used to stop or reset a color). + local nc='\033[0m' + + local service_arn="${SERVICE_ARN}" + local wait_for_deployment="${WAIT_FOR_DEPLOYMENT:-true}" + local usage_only=false + + # Function to display script usage + usage() { + usage_only=true + echo >&2 -e "${blue}Running this script will trigger a manual deployment on the passed App Runner service.${nc}" + echo >&2 -e "${blue}Usage: $0 [OPTIONS]${nc}" + echo "" + echo >&2 -e "${blue}Options:${nc}" + echo >&2 -e "${blue} -h, --help Display this message${nc}" + } + + while [ $# -gt 0 ]; do + case $1 in + -h | --help) + usage + ;; + esac + shift + done + + if [ "$usage_only" = false ]; then + if [ -z "$service_arn" ]; then + echo >&2 -e "${red}SERVICE_ARN is required but was not set.${nc}" + exit 1 + fi + + echo >&2 -e "${blue}Starting deployment for App Runner service: ${service_arn}${nc}" + + local operation_id="" + operation_id=$(aws apprunner start-deployment --service-arn "$service_arn" | jq -r '.OperationId') + + if [ -z "$operation_id" ]; then + echo >&2 -e "${red}Failed to start App Runner deployment.${nc}" + exit 1 + fi + + echo >&2 -e "${green}Deployment started. Operation ID: ${operation_id}${nc}" + + if [ "$wait_for_deployment" = "true" ]; then + echo >&2 -e "${blue}Waiting for deployment to complete (polling every 15s, timeout 10min)...${nc}" + + local max_attempts=40 # 40 * 15s = 600s = 10 minutes + local attempts=0 + + while [ "$attempts" -lt "$max_attempts" ]; do + local status="" + status=$(aws apprunner describe-service --service-arn "$service_arn" | jq -r '.Service.Status') + + echo >&2 -e "${blue}Status: ${status} (attempt $((attempts + 1))/${max_attempts})${nc}" + + if [ "$status" = "RUNNING" ]; then + echo >&2 -e "${green}Deployment completed successfully.${nc}" + return 0 + elif [[ "$status" == *"FAILED"* ]] || [ "$status" = "DELETED" ]; then + echo >&2 -e "${red}Deployment failed with status: ${status}${nc}" + exit 1 + fi + + sleep 15 + attempts=$((attempts + 1)) + done + + echo >&2 -e "${red}Timeout: deployment did not complete within 10 minutes.${nc}" + exit 1 + fi + fi +} + +# Main script execution +update_app_runner "$@" diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index e518018..9fabc76 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -63,6 +63,12 @@ jobs: contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/update-aws-ecs') run: bats --verbose-run tests/unit/update-aws-ecs/ + - name: Test - update-aws-app-runner + if: > + contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/update-aws-app-runner') || + contains(steps.changed-actions.outputs.all_changed_files, 'tests/unit/update-aws-app-runner') + run: bats --verbose-run tests/unit/update-aws-app-runner/ + - name: Test - update-aws-lambda if: > contains(steps.changed-actions.outputs.all_changed_files, '.github/actions/update-aws-lambda') || diff --git a/tests/unit/update-aws-app-runner/test_update_app_runner.bats b/tests/unit/update-aws-app-runner/test_update_app_runner.bats new file mode 100644 index 0000000..47398a0 --- /dev/null +++ b/tests/unit/update-aws-app-runner/test_update_app_runner.bats @@ -0,0 +1,222 @@ +#!/usr/bin/env bats +# ============================================================================= +# test_update_app_runner.bats +# Unit tests for update-aws-app-runner/scripts/update_app_runner.sh +# +# Tests cover: +# - --help flag (exits 0, no AWS calls) +# - Missing SERVICE_ARN (exits 1) +# - AWS CLI invocation with correct arguments (using a mock aws binary) +# - Failure when aws apprunner start-deployment returns empty response +# - WAIT_FOR_DEPLOYMENT=false skips polling +# - WAIT_FOR_DEPLOYMENT=true polls describe-service until RUNNING +# - Failure when describe-service returns a FAILED status +# +# Strategy: setup() prepends a MOCK_DIR to PATH and exports it. All run +# bash -c "..." subshells inherit this PATH automatically — do NOT override +# PATH inside the subshell, as that breaks system tools like dirname. +# ============================================================================= + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" +UPDATE_APP_RUNNER_SCRIPT="$REPO_ROOT/.github/actions/update-aws-app-runner/scripts/update_app_runner.sh" + +setup() { + MOCK_DIR="$(mktemp -d)" + export MOCK_DIR + + # Mock aws: records all calls; returns real-looking JSON for known sub-commands + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"start-deployment"* ]]; then + echo '{"OperationId":"test-operation-id-12345"}' +elif [[ "\$*" == *"describe-service"* ]]; then + echo '{"Service":{"Status":"RUNNING","ServiceArn":"arn:aws:apprunner:us-east-1:123:service/test/abc"}}' +fi +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + # Mock sleep (no-op so tests don't block) + printf '#!/bin/sh\nexit 0\n' > "$MOCK_DIR/sleep" + chmod +x "$MOCK_DIR/sleep" + + # Mock tput (avoids "no terminal" errors in CI) + printf '#!/bin/sh\nexit 0\n' > "$MOCK_DIR/tput" + chmod +x "$MOCK_DIR/tput" + + # Prepend mock dir — subshells inherit this PATH + export PATH="$MOCK_DIR:$PATH" + + export SERVICE_ARN="arn:aws:apprunner:us-east-1:123456789012:service/my-service/abc123" + export WAIT_FOR_DEPLOYMENT="false" +} + +teardown() { + [ -n "${MOCK_DIR:-}" ] && rm -rf "$MOCK_DIR" + unset SERVICE_ARN WAIT_FOR_DEPLOYMENT +} + +# --------------------------------------------------------------------------- +# --help flag +# --------------------------------------------------------------------------- + +@test "update_app_runner: --help exits 0 without running the main body" { + run bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/test/abc' + set -- --help + source '$UPDATE_APP_RUNNER_SCRIPT' + " + [ "$status" -eq 0 ] +} + +@test "update_app_runner: --help does not call aws apprunner start-deployment" { + run bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/test/abc' + set -- --help + source '$UPDATE_APP_RUNNER_SCRIPT' + " + [ ! -f "$MOCK_DIR/aws_calls.log" ] || ! grep -q "start-deployment" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Missing SERVICE_ARN +# --------------------------------------------------------------------------- + +@test "update_app_runner: exits 1 when SERVICE_ARN is not set" { + run bash -c " + unset SERVICE_ARN + export WAIT_FOR_DEPLOYMENT='false' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + [ "$status" -eq 1 ] +} + +# --------------------------------------------------------------------------- +# Successful execution +# --------------------------------------------------------------------------- + +@test "update_app_runner: calls aws apprunner start-deployment" { + bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/test/abc' + export WAIT_FOR_DEPLOYMENT='false' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + grep -q "start-deployment" "$MOCK_DIR/aws_calls.log" +} + +@test "update_app_runner: passes SERVICE_ARN to start-deployment" { + bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/my-app/abc123' + export WAIT_FOR_DEPLOYMENT='false' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + grep -q "arn:aws:apprunner:us-east-1:123:service/my-app/abc123" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# Failure: empty response from start-deployment +# --------------------------------------------------------------------------- + +@test "update_app_runner: exits 1 when start-deployment returns empty response" { + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +# Intentionally return nothing for start-deployment +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + run bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/test/abc' + export WAIT_FOR_DEPLOYMENT='false' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + [ "$status" -eq 1 ] +} + +# --------------------------------------------------------------------------- +# WAIT_FOR_DEPLOYMENT=false +# --------------------------------------------------------------------------- + +@test "update_app_runner: when WAIT_FOR_DEPLOYMENT=false, does not call describe-service" { + bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/test/abc' + export WAIT_FOR_DEPLOYMENT='false' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + [ ! -f "$MOCK_DIR/aws_calls.log" ] || ! grep -q "describe-service" "$MOCK_DIR/aws_calls.log" +} + +# --------------------------------------------------------------------------- +# WAIT_FOR_DEPLOYMENT=true +# --------------------------------------------------------------------------- + +@test "update_app_runner: when WAIT_FOR_DEPLOYMENT=true, calls describe-service" { + bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/test/abc' + export WAIT_FOR_DEPLOYMENT='true' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + grep -q "describe-service" "$MOCK_DIR/aws_calls.log" +} + +@test "update_app_runner: exits 0 when service reaches RUNNING status" { + run bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/test/abc' + export WAIT_FOR_DEPLOYMENT='true' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + [ "$status" -eq 0 ] +} + +@test "update_app_runner: exits 1 when service reaches UPDATE_FAILED status" { + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"start-deployment"* ]]; then + echo '{"OperationId":"test-op-id"}' +elif [[ "\$*" == *"describe-service"* ]]; then + echo '{"Service":{"Status":"UPDATE_FAILED"}}' +fi +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + run bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/test/abc' + export WAIT_FOR_DEPLOYMENT='true' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "update_app_runner: exits 1 when service reaches CREATE_FAILED status" { + cat > "$MOCK_DIR/aws" << MOCK +#!/bin/bash +echo "\$@" >> "${MOCK_DIR}/aws_calls.log" +if [[ "\$*" == *"start-deployment"* ]]; then + echo '{"OperationId":"test-op-id"}' +elif [[ "\$*" == *"describe-service"* ]]; then + echo '{"Service":{"Status":"CREATE_FAILED"}}' +fi +exit 0 +MOCK + chmod +x "$MOCK_DIR/aws" + + run bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/test/abc' + export WAIT_FOR_DEPLOYMENT='true' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + [ "$status" -eq 1 ] +} + +@test "update_app_runner: passes SERVICE_ARN to describe-service when waiting" { + bash -c " + export SERVICE_ARN='arn:aws:apprunner:us-east-1:123:service/my-app/abc123' + export WAIT_FOR_DEPLOYMENT='true' + source '$UPDATE_APP_RUNNER_SCRIPT' + " + grep "describe-service" "$MOCK_DIR/aws_calls.log" | grep -q "arn:aws:apprunner:us-east-1:123:service/my-app/abc123" +}