Deployment

Environments

samconfig.toml has one config environment per stack. global parameters (stack name, region, and EnvName as the only parameter override) apply to build, deploy and validate. Everything else that differs between environments is in the Env mapping of template.yaml.

stage= Stack Account Deployed by

prod

actionstools-prod

625663093496

GitHub Actions on every push to release

stage

actionstools-stage

605668041275

GitHub Actions on every push to develop

By hand

aws sso login --profile dev
AWS_PROFILE=dev make deploy stage=stage

make deploy runs, in order:

  1. uv export --frozen --no-dev … -o requirements.txt — the locked runtime dependencies.

  2. uv run sam build --config-env <stage> — uv run puts .venv’s Python 3.12 (with setuptools) first on `PATH; SAM’s pip builder uses it to build the git-sourced SDKs.

  3. sam deploy --config-env <stage> --no-confirm-changeset --no-fail-on-empty-changeset — uploads to the SAM-managed bucket (resolve_s3) and applies a change set with CAPABILITY_IAM.

Preview a change without applying it:

make build stage=stage
AWS_PROFILE=dev sam deploy --config-env stage --confirm-changeset

CI/CD (GitHub Actions)

Both workflows run the same make targets as a developer, so there are no CI-only scripts. uv and the SAM CLI are pinned by UV_VERSION / SAM_CLI_VERSION at the top of each workflow.

Workflow Runs on Does

test.yml

every pull request; called by deploy.yml

uv sync --frozen (fails if uv.lock is stale), make lint, make test, and sam validate --lint for both config environments. No AWS access.

deploy.yml

push to develop or release

Runs test.yml, then make deploy stage=<env> and a smoke test: an unauthenticated GET <ApiUrl>notes/ must answer 4xx. A 5xx means the cold start failed, usually because of a missing secret.

A push to develop deploys stage and a push to release deploys prod; no other branch deploys, and there is no manual trigger (use Re-run jobs on a run to deploy the same commit again, or deploy by hand). Deploys to one environment are serialised (concurrency), and the job runs in the GitHub environment of the same name: add required reviewers to the prod environment to gate production deploys.

Secrets

All three are organisation secrets shared with this repository; the repository defines none of its own.

Secret Used for

PYB_GITHUB_TOKEN

A token with read access to the private github.com/Payability SDK repos, put in a url.insteadOf rule so uv sync can fetch them. The step fails with an explicit error if it is empty.

DEV_AWS_ACCESS_KEY_ID, DEV_AWS_SECRET_ACCESS_KEY

Deploy user in the dev account (605668041275), for stage.

PROD_AWS_ACCESS_KEY_ID, PROD_AWS_SECRET_ACCESS_KEY

Deploy user in the prod account (625663093496), for prod.

List what the repository can see with gh api repos/Payability/ActionsToolsApp/actions/organization-secrets --jq '.secrets[].name'. Secrets are not passed to workflows triggered by Dependabot or by pull requests from forks, so those test runs fail at the SDK step.

Permissions of the deploy user

SAM deploys through CloudFormation, so the deploy identity needs CloudFormation, IAM (the stack creates the functions' roles), Lambda, API Gateway, EventBridge and the SAM artifacts bucket. A policy scoped to the prod stack (for stage, replace the account and prod with 605668041275 and stage):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CloudFormation",
      "Effect": "Allow",
      "Action": [
        "cloudformation:CreateChangeSet",
        "cloudformation:DescribeChangeSet",
        "cloudformation:ExecuteChangeSet",
        "cloudformation:DeleteChangeSet",
        "cloudformation:DescribeStacks",
        "cloudformation:DescribeStackEvents",
        "cloudformation:DescribeStackResources",
        "cloudformation:GetTemplate",
        "cloudformation:GetTemplateSummary",
        "cloudformation:ListStackResources"
      ],
      "Resource": [
        "arn:aws:cloudformation:us-east-1:625663093496:stack/actionstools-prod/*",
        "arn:aws:cloudformation:us-east-1:625663093496:stack/aws-sam-cli-managed-default/*",
        "arn:aws:cloudformation:us-east-1:aws:transform/Serverless-2016-10-31"
      ]
    },
    {
      "Sid": "SamArtifactsBucket",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": [
        "arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*",
        "arn:aws:s3:::aws-sam-cli-managed-default-samclisourcebucket-*/*"
      ]
    },
    {
      "Sid": "FunctionRoles",
      "Effect": "Allow",
      "Action": [
        "iam:CreateRole",
        "iam:DeleteRole",
        "iam:GetRole",
        "iam:TagRole",
        "iam:UntagRole",
        "iam:PutRolePolicy",
        "iam:DeleteRolePolicy",
        "iam:GetRolePolicy",
        "iam:AttachRolePolicy",
        "iam:DetachRolePolicy",
        "iam:PassRole"
      ],
      "Resource": "arn:aws:iam::625663093496:role/actionstools-prod-*"
    },
    {
      "Sid": "Functions",
      "Effect": "Allow",
      "Action": [
        "lambda:CreateFunction",
        "lambda:DeleteFunction",
        "lambda:GetFunction",
        "lambda:GetFunctionConfiguration",
        "lambda:UpdateFunctionCode",
        "lambda:UpdateFunctionConfiguration",
        "lambda:ListTags",
        "lambda:TagResource",
        "lambda:UntagResource",
        "lambda:AddPermission",
        "lambda:RemovePermission",
        "lambda:PutFunctionEventInvokeConfig",
        "lambda:UpdateFunctionEventInvokeConfig",
        "lambda:DeleteFunctionEventInvokeConfig",
        "lambda:GetFunctionEventInvokeConfig"
      ],
      "Resource": "arn:aws:lambda:us-east-1:625663093496:function:actionstools-prod-*"
    },
    {
      "Sid": "LogGroups",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:DeleteLogGroup",
        "logs:PutRetentionPolicy",
        "logs:DeleteRetentionPolicy",
        "logs:TagResource",
        "logs:UntagResource",
        "logs:ListTagsForResource"
      ],
      "Resource": "arn:aws:logs:us-east-1:625663093496:log-group:/aws/lambda/actionstools-prod-*"
    },
    {
      "Sid": "DescribeLogGroups",
      "Effect": "Allow",
      "Action": ["logs:DescribeLogGroups"],
      "Resource": "*"
    },
    {
      "Sid": "EventSourceMappings",
      "Effect": "Allow",
      "Action": [
        "lambda:CreateEventSourceMapping",
        "lambda:UpdateEventSourceMapping",
        "lambda:DeleteEventSourceMapping",
        "lambda:GetEventSourceMapping"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ApiGateway",
      "Effect": "Allow",
      "Action": ["apigateway:GET", "apigateway:POST", "apigateway:PUT", "apigateway:PATCH", "apigateway:DELETE"],
      "Resource": ["arn:aws:apigateway:us-east-1::/restapis", "arn:aws:apigateway:us-east-1::/restapis/*", "arn:aws:apigateway:us-east-1::/tags/*"]
    },
    {
      "Sid": "Schedule",
      "Effect": "Allow",
      "Action": [
        "events:PutRule",
        "events:DeleteRule",
        "events:DescribeRule",
        "events:EnableRule",
        "events:DisableRule",
        "events:PutTargets",
        "events:RemoveTargets"
      ],
      "Resource": "arn:aws:events:us-east-1:625663093496:rule/actionstools-prod-*"
    },
    {
      "Sid": "VpcLookups",
      "Effect": "Allow",
      "Action": ["ec2:DescribeSecurityGroups", "ec2:DescribeSubnets", "ec2:DescribeVpcs"],
      "Resource": "*"
    }
  ]
}

The first sam deploy in an account creates the aws-sam-cli-managed-default stack and its bucket; if the deploy user should not be allowed to do that, run the first deploy by hand.

Rollback

  • Bad code: revert the commit on release; CI redeploys the previous version. To skip CI, deploy the previous commit by hand: git checkout <sha> && AWS_PROFILE=prod make deploy stage=prod.

  • Stuck update: aws cloudformation cancel-update-stack --stack-name actionstools-prod while it is in progress.

  • Failed update: CloudFormation rolls back automatically; the stack events name the failing resource.

Data is never touched by a rollback: the tables are outside the stack.

Smoke test after a deploy

URL=$(aws cloudformation describe-stacks --stack-name actionstools-stage \
  --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" --output text --profile dev)
curl -s -o /dev/null -w '%{http_code}\n' "$URL"                        # 200, Swagger UI
curl -s "$URL/notes/" -H "X-Session-Token: $TOKEN" \
  -H "X-Actions-TargetUUID: <supplier key>" | jq .status_code          # 200
sam logs --stack-name actionstools-stage --profile dev --tail          # API and Worker logs