Hands-On: Continuous Delivery to Free Clouds

The goal of this session is one release pipeline that delivers the Python and Java apps to free cloud hosting. Each tag builds once, publishes the artifacts, deploys to Azure App Service, AWS Elastic Beanstalk, and Google App Engine, verifies every target with the same smoke test, and moves traffic only after verification. You will also roll back each target. No Docker is used.

Contents
  1. End-to-end runbook (start here)
  2. Session plan and prerequisites
  3. Part 1: Continuous delivery design and free targets
  4. Part 2: One smoke test for every target
  5. Part 3: Cloud-ready entry points
  6. Part 4: Google Cloud free project and App Engine
  7. Part 5: Deploy by hand to App Engine
  8. Part 6: Secrets, variables, and environments
  9. Part 7: The Python release and deploy pipeline
  10. Part 8: The Java release and deploy pipeline
  11. Part 9: Release 1.2.1 to every target
  12. Part 10: Access the endpoints
  13. Part 11: Roll back each target
  14. Part 12: Cleanup, recap, and troubleshooting

End-to-end runbook (start here)

This section is the complete order of work, from empty accounts to cleanup. Apart from the calculator, its unit tests, and the basic CI workflow from the artifacts session, every file and command you need is in this document. The later parts of the document expand on each step, so you can read them in the order given here.

Order of work

PhaseWhat you doWhereTime
A. AccountsCreate Azure, AWS, and Google Cloud accounts with budget alertsPhase A below25 min, plus verification waits
B. ToolsInstall and sign in to the CLIsPhase B below10 min
C. Endpoint codeAdd /health, /add, and /divide to both apps and commitPhase C below15 min
E. Smoke test and App Engine configCreate the shared smoke test script and the App Engine filesParts 2 and 310 min
D. Cloud resourcesCreate the Azure apps, the AWS deployer user and Elastic Beanstalk environments, and the Google projectPhase D below25 min
F. Pipelines and secretsAdd the release workflows, then set variables, secrets, and environments from the command linePhase F below, with Parts 6, 7, and 815 min
G. First releaseTag v1.2.0 in both repositories so all three targets deployPhase G below10 min
H. Second release and rollbackRelease 1.2.1 and roll back one targetParts 9, 10, and 1125 min
I. CleanupRemove resources in all three cloudsPart 1210 min
Phases C and E come before D on purpose. The Elastic Beanstalk environments are created from a wheel and a jar built locally, so they do not need the GitHub Release to exist yet. Part 5 is an optional manual walk-through of the App Engine steps, and it can run after Phase G.

Phase A: Accounts About 60 min including verification waits

Do Phase A a day before the session if you can. Verification can take minutes or a day, and a stuck account wastes lab time. Complete the three accounts in the order A1, A2, A3.

Before you start

ItemWhy it matters
A personal email address for each providerWork accounts may block lab resources. Keep the lab under your own control.
A mobile phone that can receive SMS and voice callsEvery provider verifies the phone during sign-up.
A credit or debit card in your nameRequired for the standard free account on all three providers.
International or online card transactions enabledMany Indian-issued cards are declined without this. Enable it in your bank's app before you start.
An authenticator app on your phoneUsed for MFA on each account. Any TOTP app works, such as Microsoft Authenticator or Google Authenticator.
A password managerYou will store three root passwords and several access keys.

Step A1 Azure account

  1. Start sign-up. Open azure.microsoft.com/free in a private browser window. Choose Start free (the label may read Try for free).
  2. Sign in with a Microsoft account. Use a personal account. If you do not have one, choose Create one and use your personal email address as the sign-in name.
  3. Enter your profile. Choose your country or region, enter your first and last name, and enter your mobile number. Choose Text me or Call me, then enter the six-digit code that arrives.
  4. Accept the agreements. Tick the subscription agreement and offer statement boxes, then choose Next.
  5. Add the card. Enter the card number, expiry date, and security code. Use your billing address. Microsoft places a small temporary authorisation to check the card. It is released or refunded, but it can show as pending for several days.
  6. Handle a declined card. Check that the card is not prepaid or restricted, that international or online transactions are enabled in your bank's app, and that the billing address matches the card. Then try again or use a different card.
  7. Finish and open the portal. Complete the final screen and open portal.azure.com.
  8. Check the subscription. In the portal search bar, type Subscriptions, open it, and confirm a free subscription is listed with the status Active. Note its name and ID.
  9. Turn on MFA. Open aka.ms/mfasetup and follow the prompts to register your authenticator app. Then open Microsoft Entra ID in the portal, choose Properties, then Manage security defaults, and set it to Enabled. Security defaults require MFA for sign-in and protect the account from common attacks. Save the recovery codes shown in the security settings.
  10. Create a budget alert. In the portal search bar, type Cost Management, open it, and choose Budgets, then Add. Set the scope to your free subscription, name the budget lab-guard, set the reset period to Monthly, and set the amount to 10 in your currency. On the alert conditions step, add an alert at 80 percent of actual cost and another at 100 percent of forecasted cost. Enter your email address as the alert recipient, then choose Create. If the budget option is missing on your subscription type, use the cost alerts page under Cost Management and set a daily spend alert instead.
  11. Check from the command line. After Phase B installs the Azure CLI, run az login, then az account show --output table.
Free account terms. The first 30 days come with credit that can be used on most services. Some services are free for 12 months, and some are always free within limits. App Service F1 is in the always-free group, and the lab uses only that tier.
Students without a card. Azure for Students needs a verified school or university email address and no card. Choose it at azure.microsoft.com/free/students if your institution is eligible. Its subscription is the same kind of target for this lab.

Step A2 AWS account

  1. Start sign-up. Open aws.amazon.com in a private browser window and choose Create an AWS account.
  2. Enter the root email and account name. Use your personal email address. For the account name, enter something like lab-personal. Choose Verify email address, then enter the six-digit code that AWS emails you.
  3. Set the root password. Use a password of at least 16 characters, stored in your password manager. Choose Continue.
  4. Choose the account type. Select Personal unless the account is for an organisation. Enter your full name, country, address, and phone number. Accept the customer agreement.
  5. Verify the phone. Choose Text message (SMS) or Voice call, complete the CAPTCHA, and enter the code you receive.
  6. Select the support plan. Choose Basic support (free).
  7. Add the card. Enter the card and billing address. AWS places a small temporary authorisation to check the card, which is reversed.
  8. Complete identity verification. AWS may call the phone number and read out a code. Enter it when prompted.
  9. Choose the plan if offered. If the sign-up flow shows plan choices, pick the free plan that includes credits for new accounts. Read the list of services it excludes, because the lab uses Elastic Beanstalk, EC2, and IAM.
  10. Turn on MFA for the root user. Sign in at console.aws.amazon.com as root. Open the account menu in the top-right corner, choose Security credentials, then Assign MFA device. Choose a passkey, a security key, or an authenticator app, and follow the steps. Keep the recovery method somewhere safe.
  11. Confirm there are no root access keys. On the same page, check that the access keys section is empty. Never create root access keys for this lab.
  12. Create the administrator user. Open the IAM service and choose Users, then Create user. Name it admin-you and tick Provide user access to the AWS Management Console. Choose a custom password and clear Users must create a new password at next sign-in. On the permissions step, choose Attach policies directly and select AdministratorAccess. Create the user.
  13. Turn on MFA for the admin user. Open the admin-you user, choose Security credentials, then Assign MFA device. Register a passkey or an authenticator app.
  14. Note the sign-in URL. On the IAM dashboard, find the AWS account ID and the Account sign-in URL. Bookmark the URL, because IAM users sign in through it, not through the root sign-in page.
  15. Let IAM users see billing. Sign in as root. Open the account menu, choose Account, and under IAM user and role access to Billing information, choose Edit, tick Activate IAM Access, and save.
  16. Create the budget alerts. Open Billing and Cost Management, then Budgets, then Create budget. Choose the Monthly cost budget template, name it lab-guard, and set the budgeted amount to 10. Add an alert at 80 percent of the budgeted amount, sent to your email address. Create a second budget with the Zero spend budget template, which sends an alert on any spend at all.
  17. Check the free plan. Open Billing and Cost Management and the Free Tier page. Note the credit balance and the expiry date, if the plan shows them.
  18. Sign in as the admin user. Sign out of root, open the account sign-in URL from step 14, and sign in as admin-you with MFA. Use this user for all later work.
Root user rules. Use root only for account and billing settings. Never create root access keys, and keep root MFA enabled. The administrator user creates the keys that the lab uses, and the deployer user in Phase D2 is narrower still.

Step A3 Google Cloud account and billing

  1. Turn on 2-Step Verification first. Open myaccount.google.com with a personal Google account, choose Security, then 2-Step Verification, and follow the steps to add your phone or an authenticator app. Save the backup codes.
  2. Start the free trial. Open cloud.google.com, choose Get started for free or Try for free, and sign in.
  3. Enter the account details. Choose your country, select Individual as the account type, enter your name and address, and accept the terms.
  4. Add the payment method. Enter a credit or debit card. Google places a small temporary check on it. The trial credit and the always-free limits apply to the billing account you create here.
  5. Create the billing account. Google creates a billing account during sign-up. Open Billing in the console and note the billing account name and ID. The CLI will show the same ID in Phase D3.
  6. Create a budget alert. In the console, open Billing, then Budgets and alerts, then Create budget. Name it lab-guard, choose the billing account, and set the budget amount to 10 in your currency. Set alert thresholds at 50, 90, and 100 percent, and enter your email address for notifications.
  7. Read what a budget does. A budget sends alerts only. It does not stop spending by itself. To stop spending, delete the resources, which Part 12 covers.
  8. Check the trial status. Open Billing and confirm the free trial is shown as active, with its remaining credit and end date.
Google's trial terms, its credit amount, and its trial length change from time to time. Read the current terms on the billing page before relying on them.

Phase A check

Phase B: Tools About 25 min

Step B1 Install the tools

Install each tool with the package manager for your system, or with its official installer. The commands below are the usual ones. If a command name differs on your version, follow the tool's installation page.

ToolmacOS (Homebrew)Ubuntu or DebianWindows (winget)
Azure CLIbrew install azure-cliFollow Microsoft's Linux install pagewinget install Microsoft.AzureCLI
AWS CLI v2brew install awscliDownload the v2 bundle from AWS and run its installerwinget install Amazon.AWSCLI
Google Cloud CLIbrew install --cask google-cloud-sdkFollow the Google Cloud CLI install page for Debian or Ubuntuwinget install Google.CloudSDK
GitHub CLIbrew install ghFollow the GitHub CLI install page for Linuxwinget install GitHub.cli
jqbrew install jqsudo apt-get install -y jqwinget install jqlang.jq
zip and unzipIncludedsudo apt-get install -y zip unzipUse Git Bash, which includes them
Python 3.11 or laterbrew install pythonsudo apt-get install -y python3 python3-venv python3-pipwinget install Python.Python.3.12
JDK 17 and Mavenbrew install openjdk@17 mavensudo apt-get install -y openjdk-17-jdk mavenwinget install EclipseAdoptium.Temurin.17.JDK and winget install Apache.Maven
Windows users: open a new terminal after each installation so the new commands are on the path. The commands in this document assume a POSIX shell, so use Git Bash or WSL for the rest of the lab.

Step B2 Verify each installation

az version
aws --version
gcloud --version
gh --version
jq --version
zip -h | head -1
python3 --version
java -version
mvn -version
Checkpoint: every command prints a version number. If one fails, reinstall that tool before moving on.

Step B3 Install the Elastic Beanstalk CLI

Install the EB CLI into its own virtual environment, so its dependencies do not conflict with the project environments:

python3 -m venv ~/.venvs/eb
~/.venvs/eb/bin/pip install --upgrade pip
~/.venvs/eb/bin/pip install awsebcli
~/.venvs/eb/bin/eb --version

To call it without the full path, add this line to your shell profile (~/.bashrc or ~/.zshrc), then open a new terminal:

export PATH="$HOME/.venvs/eb/bin:$PATH"

Step B4 Sign in to Azure

  1. Run az login. A browser window opens. Sign in with the Microsoft account from Phase A1 and complete the MFA prompt.
  2. If no browser is available (for example, over SSH), run az login --use-device-code and enter the code at microsoft.com/devicelogin.
  3. List your subscriptions with az account list --output table.
  4. Choose the free subscription with az account set --subscription "<subscription-name-or-id>".
  5. Confirm with az account show --output table. The default subscription should be the free one.
The CLI stores a token locally after sign-in. If commands start failing with an authentication message after a few weeks, run az login again.

Step B5 Sign in to AWS

  1. Create an access key for the admin user. Sign in to the console as admin-you. Open IAM, then Users, then admin-you, then Security credentials. Choose Create access key, select Command Line Interface (CLI), tick the confirmation box, and create the key. Download the CSV or copy the key ID and secret. The secret is shown only once.
  2. Configure the profile. Run aws configure --profile admin-you. Enter the key ID and secret, the default region us-east-1, and the output format json.
  3. Check the identity. Run aws sts get-caller-identity --profile admin-you. The Arn should end in user/admin-you.
  4. Remove the CSV. If you downloaded a CSV, delete the file after the profile works.
Access key rules. Keys are long-lived credentials. Never put them in code or in a repository. Rotate them by creating a new key, updating the profile, and deleting the old key. Later, the deployer user has its own keys, created in Phase D2.
AWS also offers IAM Identity Center with aws configure sso. It issues short-lived credentials and is the better choice for teams. This lab uses access keys because they work the same way in the pipeline.

Step B6 Sign in to Google Cloud

  1. Run gcloud init. Choose Log in with a new account, and complete the browser sign-in and 2-Step Verification.
  2. When asked for a default project, choose Create a new project or skip the step. Phase D3 creates the lab project with a unique ID.
  3. Choose a default region, such as us-central1. The App Engine region is set separately in Phase D3.
  4. Run gcloud auth login if you need to sign in again later, and gcloud config list to confirm the account and project.
  5. Check billing access with gcloud billing accounts list. The billing account from Phase A3 should appear with OPEN status.

Step B7 Sign in to GitHub with the GitHub CLI

  1. Run gh auth login.
  2. Choose GitHub.com, then HTTPS as the preferred protocol, and answer yes to authenticate Git with your GitHub credentials.
  3. Choose Login with a web browser. The CLI shows a one-time code. Copy it, press Enter, and complete the sign-in and MFA in the browser.
  4. Check the result with gh auth status. It should show your account and the token scopes.
  5. The lab sets secrets, variables, and environments, which needs the repo and workflow scopes. If they are missing, run gh auth refresh -s workflow and approve the request in the browser.
  6. Confirm that you are the owner or an admin of both lab repositories with gh repo view <your-user>/python-ci-demo --json viewerPermission --jq .viewerPermission. The value should be ADMIN. Environments and secrets cannot be changed with lower permissions.
Token handling. gh auth login stores the token in your system keychain or in ~/.config/gh. Do not copy it into scripts. To remove access later, run gh auth logout and revoke the token in GitHub's settings.

Step B8 One check for all tools

echo "Azure:  $(az account show --query name --output tsv)"
echo "AWS:    $(aws sts get-caller-identity --profile admin-you --query Arn --output text)"
echo "GCP:    $(gcloud config get-value account 2>/dev/null)"
echo "GitHub: $(gh api user --jq .login)"
echo "EB CLI: $(~/.venvs/eb/bin/eb --version)"
Checkpoint: every line shows your account, subscription, or login name. This is the state the rest of the runbook assumes.

Troubleshooting Phase A and Phase B

SymptomFix
Card declined at any providerCheck the card is not prepaid, enable international or online transactions in the bank's app, and confirm the billing address matches the card. Try a second card if one is available.
Phone code never arrivesWait two minutes, then request the call option. Check that the number includes the country code and that the phone can receive SMS.
Azure subscription shows as disabledThe card check failed or the trial ended. Open Subscriptions in the portal and follow the reactivation prompt, or contact support from the same page.
AWS account stuck in verificationWait up to a day, then check the email for a request from AWS. Use the Support contact page if it persists.
az: command not found after installingOpen a new terminal. On Windows, check that the Azure CLI folder is on the path.
aws sts get-caller-identity says invalid credentialsCheck the key ID and secret for typing mistakes. Run aws configure --profile admin-you again with the correct values.
gcloud asks for the project every timeRun gcloud config set project <project-id>, or set it in Phase D3.
gh api user returns 401Run gh auth login again, or gh auth refresh if only scopes are missing.
Permission denied when setting environments laterCheck viewerPermission in Step B7. You need admin rights on the repository.

Phase C: Endpoint code 15 min

Both repositories start from the calculator, its unit tests, and the CI workflow from the artifacts session. Phase C adds the HTTP endpoints, which every later phase deploys.

Step C1 Python: packaging, web module, and tests

cd ~/work/python-ci-demo
git checkout main && git pull
git checkout -b feat/endpoints
pyproject.toml
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "calc-ci-demo"
version = "1.2.0"
description = "Calculator demo used for CI, artifacts, and deployment lessons"
requires-python = ">=3.11"
dependencies = [
    "flask>=3.0,<4",
    "gunicorn>=22,<23",
]

[tool.setuptools.packages.find]
include = ["app*"]
app/web.py
from importlib.metadata import PackageNotFoundError, version

from flask import Flask, abort, jsonify, request

from app.calculator import add, divide

try:
    APP_VERSION = version("calc-ci-demo")
except PackageNotFoundError:
    APP_VERSION = "dev"

app = Flask(__name__)


def _two_numbers() -> tuple[float, float]:
    try:
        return float(request.args["a"]), float(request.args["b"])
    except (KeyError, ValueError):
        abort(400, description="query parameters a and b must be numbers")


@app.get("/health")
def health():
    return jsonify(status="ok", version=APP_VERSION)


@app.get("/add")
def add_endpoint():
    a, b = _two_numbers()
    return jsonify(result=add(a, b))


@app.get("/divide")
def divide_endpoint():
    a, b = _two_numbers()
    try:
        return jsonify(result=divide(a, b))
    except ValueError as exc:
        return jsonify(error=str(exc)), 400
tests/test_web.py
from app.web import app


def client():
    app.config["TESTING"] = True
    return app.test_client()


def test_health_reports_ok():
    response = client().get("/health")

    assert response.status_code == 200
    assert response.get_json()["status"] == "ok"


def test_add_endpoint_returns_sum():
    response = client().get("/add?a=2&b=3")

    assert response.get_json() == {"result": 5.0}


def test_divide_endpoint_returns_quotient():
    response = client().get("/divide?a=10&b=4")

    assert response.get_json()["result"] == 2.5


def test_divide_by_zero_returns_400():
    assert client().get("/divide?a=1&b=0").status_code == 400


def test_non_numeric_input_returns_400():
    assert client().get("/add?a=x&b=3").status_code == 400

Add these two pins to the end of requirements.txt, so CI installs the same versions as the wheel declares:

flask==3.0.3
gunicorn==22.0.0
source .venv/bin/activate
pip install -r requirements.txt
pip install -e .
pytest -q
python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])"
Checkpoint: pytest passes, and the version line prints 1.2.0.

Step C2 Java: server, manifest, and coverage exclusions

cd ~/work/java-ci-demo
git checkout main && git pull
git checkout -b feat/endpoints
src/main/java/com/example/calc/Server.java
package com.example.calc;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

public final class Server {

    private static final Calculator CALC = new Calculator();

    public static void main(String[] args) throws IOException {
        int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        server.createContext("/", Server::handle);
        server.start();
        System.out.println("calc-ci-demo listening on port " + port);
    }

    static void handle(HttpExchange exchange) throws IOException {
        String path = exchange.getRequestURI().getPath();
        try {
            switch (path) {
                case "/health" -> send(exchange, 200, "{\"status\":\"ok\",\"version\":\"" + version() + "\"}");
                case "/add" -> {
                    Map<String, String> q = query(exchange);
                    send(exchange, 200, "{\"result\":" + CALC.add(intParam(q, "a"), intParam(q, "b")) + "}");
                }
                case "/divide" -> {
                    Map<String, String> q = query(exchange);
                    send(exchange, 200, "{\"result\":" + CALC.divide(intParam(q, "a"), intParam(q, "b")) + "}");
                }
                default -> send(exchange, 404, "{\"error\":\"not found\"}");
            }
        } catch (IllegalArgumentException e) {
            send(exchange, 400, "{\"error\":\"" + e.getMessage() + "\"}");
        }
    }

    static String version() {
        Package p = Server.class.getPackage();
        String v = p == null ? null : p.getImplementationVersion();
        return v == null ? "dev" : v;
    }

    static Map<String, String> query(HttpExchange exchange) {
        Map<String, String> params = new HashMap<>();
        String raw = exchange.getRequestURI().getRawQuery();
        if (raw != null) {
            for (String pair : raw.split("&")) {
                String[] kv = pair.split("=", 2);
                if (kv.length == 2) {
                    params.put(kv[0], kv[1]);
                }
            }
        }
        return params;
    }

    static int intParam(Map<String, String> params, String name) {
        String value = params.get(name);
        if (value == null) {
            throw new IllegalArgumentException("missing parameter " + name);
        }
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(name + " must be an integer");
        }
    }

    static void send(HttpExchange exchange, int status, String body) throws IOException {
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "application/json");
        exchange.sendResponseHeaders(status, bytes.length);
        try (OutputStream out = exchange.getResponseBody()) {
            out.write(bytes);
        }
    }
}

Step C3 Java: pom changes

In pom.xml, make three changes.

  1. Set the project version: change the <version> directly under <artifactId>calc-ci-demo</artifactId> to 1.2.0.
  2. Replace the <configuration> of maven-jar-plugin with:
<configuration>
  <archive>
    <manifest>
      <mainClass>com.example.calc.Server</mainClass>
      <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
    </manifest>
  </archive>
</configuration>
  1. In the jacoco-maven-plugin block, list both entry-point classes as excluded from coverage:
<excludes>
  <exclude>com/example/calc/App.class</exclude>
  <exclude>com/example/calc/Server.class</exclude>
</excludes>
mvn -B verify
mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout
mvn -B -DskipTests package
java -jar target/calc-ci-demo-1.2.0.jar &
sleep 2
curl -s http://127.0.0.1:8080/health
curl -s "http://127.0.0.1:8080/add?a=2&b=3"
kill %1
Checkpoint: the build ends with BUILD SUCCESS, the version is 1.2.0, and /health reports version 1.2.0.

Step C4 Commit both branches

cd ~/work/python-ci-demo
git add . && git commit -m "Add Flask endpoints, declared dependencies, and tests"
git push -u origin feat/endpoints

cd ~/work/java-ci-demo
git add . && git commit -m "Add HTTP server, version manifest, and coverage exclusions"
git push -u origin feat/endpoints

Open a pull request in each repository, wait for CI to pass, and merge both into main. Then return to main:

cd ~/work/python-ci-demo && git checkout main && git pull
cd ~/work/java-ci-demo && git checkout main && git pull

Phase E: Smoke test and App Engine configuration

Complete Part 2 (the shared smoke test script) and Part 3 (the App Engine configuration files) in this document now, then return here. Both parts are needed before the pipelines run.

Phase D: Cloud resources 25 min

Step D1 Azure App Service (free F1)

az login
az account show --output table

RG=cd-lab-rg
PLAN=cd-lab-plan
LOCATION=eastus
PY_AZ=<your-unique-python-name>
JAVA_AZ=<your-unique-java-name>

az group create --name "$RG" --location "$LOCATION"
az appservice plan create --name "$PLAN" --resource-group "$RG" --is-linux --sku F1

az webapp create --name "$PY_AZ" --resource-group "$RG" --plan "$PLAN" --runtime "PYTHON:3.12"
az webapp config set --name "$PY_AZ" --resource-group "$RG" --startup-file "gunicorn --bind=0.0.0.0:8000 app.web:app"
az webapp config appsettings set --name "$PY_AZ" --resource-group "$RG" --settings SCM_DO_BUILD_DURING_DEPLOYMENT=true

az webapp create --name "$JAVA_AZ" --resource-group "$RG" --plan "$PLAN" --runtime "JAVA:17-java17"
az webapp config set --name "$JAVA_AZ" --resource-group "$RG" --startup-file "java -jar /home/site/wwwroot/app.jar"
az webapp config appsettings set --name "$JAVA_AZ" --resource-group "$RG" --settings WEBSITES_PORT=8080

az webapp deployment list-publishing-profiles --name "$PY_AZ" --resource-group "$RG" --xml > azure-py-publish.xml
az webapp deployment list-publishing-profiles --name "$JAVA_AZ" --resource-group "$RG" --xml > azure-java-publish.xml

Explanation

Step D2 AWS: deployer user and Elastic Beanstalk environments

Goal: create a narrow deployer user with its own keys, then create one single-instance environment per app. The environments are created from locally built artifacts, so they do not depend on the GitHub Release.

aws iam create-user --user-name ci-deployer --profile admin-you
for POLICY in AWSElasticBeanstalkFullAccess AmazonS3FullAccess AWSCloudFormationFullAccess; do
  aws iam attach-user-policy --user-name ci-deployer --policy-arn "arn:aws:iam::aws:policy/$POLICY" --profile admin-you
done
aws iam create-access-key --user-name ci-deployer --profile admin-you

Save the AccessKeyId and SecretAccessKey values from the last command. The secret is shown only once. Then configure the deployer profile:

aws configure --profile ci-deployer
# Default region name: us-east-1
# Default output format: json
aws sts get-caller-identity --profile ci-deployer
export AWS_PROFILE=ci-deployer

Build the Python wheel and the Java jar locally, then create the bundles:

cd ~/work/python-ci-demo
source .venv/bin/activate
pip install --upgrade build
python -m build
mkdir -p ~/work/aws-py
cp dist/calc_ci_demo-1.2.0-py3-none-any.whl ~/work/aws-py/
printf '%s\n' "./calc_ci_demo-1.2.0-py3-none-any.whl" > ~/work/aws-py/requirements.txt
printf '%s\n' "from app.web import app as application" > ~/work/aws-py/application.py

cd ~/work/java-ci-demo
mvn -B -DskipTests package
mkdir -p ~/work/aws-java
cp target/calc-ci-demo-1.2.0.jar ~/work/aws-java/application.jar
cd ~/work/aws-py
~/.venvs/eb/bin/eb init calc-py-app --platform python-3.12 --region us-east-1
~/.venvs/eb/bin/eb create calc-py-env --single --instance_type t3.micro --timeout 20

cd ~/work/aws-java
~/.venvs/eb/bin/eb init calc-java-app --platform corretto-17 --region us-east-1
~/.venvs/eb/bin/eb create calc-java-env --single --instance_type t3.micro --timeout 20
~/.venvs/eb/bin/eb setenv PORT=5000 --environment calc-java-env
Checkpoint: both Elastic Beanstalk environments reach Green, and the endpoints in Part 10 respond with version 1.2.0 once Phase G has run. Until then, /health on the Python environment reports the wheel version you built locally.

Step D3 Google Cloud project and deployer identity

export GCP_PROJECT=calc-cd-7421
gcloud projects create "$GCP_PROJECT" --name="calc cd lab"
gcloud billing accounts list
gcloud billing projects link "$GCP_PROJECT" --billing-account=<billing-account-id>
gcloud config set project "$GCP_PROJECT"
gcloud services enable appengine.googleapis.com cloudbuild.googleapis.com
gcloud app create --region=us-central
gcloud app describe --format="value(defaultHostname)"

gcloud iam service-accounts create ci-deployer --display-name="CI deployer"
SA=ci-deployer@"$GCP_PROJECT".iam.gserviceaccount.com
for ROLE in roles/appengine.deployer roles/appengine.serviceAdmin roles/cloudbuild.builds.editor roles/storage.admin roles/iam.serviceAccountUser roles/serviceusage.serviceUsageConsumer; do
  gcloud projects add-iam-policy-binding "$GCP_PROJECT" --member="serviceAccount:$SA" --role="$ROLE" --condition=None
done
gcloud iam service-accounts keys create gcp-ci-key.json --iam-account="$SA"

Explanation

Key handling. gcp-ci-key.json is a credential. Store it as a GitHub secret in Phase F, then delete the local copy.

Phase F: Pipelines and secrets 15 min

Step F1 Add the release workflows

Create .github/workflows/release.yml in python-ci-demo exactly as shown in Part 7, and in java-ci-demo exactly as shown in Part 8. Commit each on a branch, open a pull request, and merge once CI passes. Tags do not exist yet, so nothing is deployed at this point.

Step F2 Set variables, secrets, and environments from the command line

Using the GitHub CLI keeps the values out of the browser and out of your shell history. The secret values are read from files or a hidden prompt.

OWNER=<your-github-user>
PY_REPO="$OWNER/python-ci-demo"
JAVA_REPO="$OWNER/java-ci-demo"

# Python repository: Azure
gh variable set AZURE_PY_APP --body "$PY_AZ" --repo "$PY_REPO"
gh variable set AZURE_PY_HOST --body "$PY_AZ.azurewebsites.net" --repo "$PY_REPO"
gh secret set AZURE_PY_PUBLISH_PROFILE --repo "$PY_REPO" < azure-py-publish.xml

# Java repository: Azure
gh variable set AZURE_JAVA_APP --body "$JAVA_AZ" --repo "$JAVA_REPO"
gh variable set AZURE_JAVA_HOST --body "$JAVA_AZ.azurewebsites.net" --repo "$JAVA_REPO"
gh secret set AZURE_JAVA_PUBLISH_PROFILE --repo "$JAVA_REPO" < azure-java-publish.xml

# Both repositories: AWS deployer and region
read -rs AWS_SECRET && echo
for REPO in "$PY_REPO" "$JAVA_REPO"; do
  gh variable set AWS_REGION --body us-east-1 --repo "$REPO"
  gh secret set AWS_ACCESS_KEY_ID --body "<access-key-id>" --repo "$REPO"
  printf '%s' "$AWS_SECRET" | gh secret set AWS_SECRET_ACCESS_KEY --repo "$REPO"
  gh variable set GCP_PROJECT --body "$GCP_PROJECT" --repo "$REPO"
  gh secret set GCP_SA_KEY --repo "$REPO" < gcp-ci-key.json
done
unset AWS_SECRET

# AWS environment URLs (CNAME from the environments created in Phase D2)
gh variable set AWS_PY_URL --body "$(cd ~/work/aws-py && ~/.venvs/eb/bin/eb status calc-py-env | awk '/CNAME/ {print $2}')" --repo "$PY_REPO"
gh variable set AWS_JAVA_URL --body "$(cd ~/work/aws-java && ~/.venvs/eb/bin/eb status calc-java-env | awk '/CNAME/ {print $2}')" --repo "$JAVA_REPO"

# Google Cloud host names
GCP_HOST=$(gcloud app describe --format="value(defaultHostname)")
gh variable set GCP_PY_HOST --body "$GCP_HOST" --repo "$PY_REPO"
gh variable set GCP_JAVA_HOST --body "calc-java-dot-$GCP_HOST" --repo "$JAVA_REPO"

# Environments that gate each target
for ENV in azure-free aws-free gcp-free; do
  gh api -X PUT "repos/$PY_REPO/environments/$ENV" > /dev/null
  gh api -X PUT "repos/$JAVA_REPO/environments/$ENV" > /dev/null
done

Explanation

Clean up the credential files. After the secrets are saved, delete azure-py-publish.xml, azure-java-publish.xml, and gcp-ci-key.json from your machine.

Step F3 Confirm the configuration

gh variable list --repo "$PY_REPO"
gh secret list --repo "$PY_REPO"
gh variable list --repo "$JAVA_REPO"
gh secret list --repo "$JAVA_REPO"
gh api "repos/$PY_REPO/environments" --jq '.environments[].name'
Checkpoint: each repository lists every name from the table in Step 6.1, and the three environments exist in both.

Phase G: First release, v1.2.0 10 min

Step G1 Check the versions

cd ~/work/python-ci-demo
git checkout main && git pull
./scripts/check_version.sh v1.2.0

cd ~/work/java-ci-demo
git checkout main && git pull
mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout

The Python check prints OK: all versions match 1.2.0, and Maven prints 1.2.0. Also confirm npm/package.json says 1.2.0, because the check covers it.

Step G2 Tag and release

cd ~/work/python-ci-demo
git tag -a v1.2.0 -m "Release 1.2.0: HTTP endpoints and multi-cloud delivery"
git push origin v1.2.0

cd ~/work/java-ci-demo
git tag -a v1.2.0 -m "Release 1.2.0: HTTP endpoints and multi-cloud delivery"
git push origin v1.2.0

In each repository, open the Actions run for v1.2.0. Expect this sequence:

  1. build and release pass, and the release lists the wheel, sdist, npm tarball, and checksums (Python) or the jar and checksum (Java).
  2. Three deploy jobs run: Azure, AWS, and Google Cloud. Each ends with a smoke test line that reads OK: ... serves version 1.2.0.
  3. In the Google Cloud job, the version is deployed without traffic, verified, and only then promoted.
Checkpoint: six deploy jobs pass across the two repositories, and each target answers /health with version 1.2.0.

Step G3 Optional: see the App Engine steps by hand

Run Part 5 now if you want to see each App Engine command outside the pipeline. The pipeline has already performed the same deploy, so Part 5 is for learning and troubleshooting.

Phases H and I

Phase H is Parts 9, 10, and 11 of this document: release 1.2.1 to every target, check the endpoints, and roll back one target. Phase I is Part 12: remove the resources in all three clouds and the credentials in GitHub.

Session plan and prerequisites

TimePartWhat you finish
0:00 to 0:101. Design and free targetsHow one release reaches several clouds
0:10 to 0:202 and 3. Smoke test and entry pointsOne verification script and cloud-ready apps
0:20 to 0:354. Google Cloud projectFree project, App Engine enabled, CI service account
0:35 to 0:455. Manual App Engine deployPython and Java deployed by hand, once
0:45 to 0:556. Secrets and environmentsAll variables and secrets set in both repositories
0:55 to 1:107 and 8. PipelinesRelease workflows with three deploy targets each
1:10 to 1:209. Release 1.2.1Every target updated from one tag
1:20 to 1:3010 and 11. Access and rollbackEndpoints checked, one target rolled back
1:30 to 2:0012. Cleanup and recapResources removed in all three clouds

Prerequisites

RequirementHow to check
Tag v1.2.0 in python-ci-demo and java-ci-demo, with the endpointsReleases page of each repository
Azure App Service F1 plan and two web apps, created in Phase D1az webapp list --output table
Two Elastic Beanstalk environments, created in Phase D2eb list in the AWS project folders
A Google account with a billing account (a card is required to create one)Google Cloud console, Billing
Tools: Python 3.11+, JDK 17 with Maven, gcloud, curl, jq, zipgcloud --version, jq --version
If these resources do not exist yet, create them in Phase D of the runbook above. The pipelines deploy to all three targets, so each target needs its resources in place first.

Part 1: Continuous delivery design and free targets 10 min

Step 1.1 The delivery flow

tag vX.Y.Z
  -> test
  -> build once (wheel, sdist, npm tarball, jar, checksums)
  -> publish GitHub Release
  -> deploy to Azure, AWS, and Google Cloud in parallel
  -> smoke test each target against the released version
  -> Google Cloud: promote traffic only after its smoke test passes

Explanation

Step 1.2 Compare the free targets

TargetPythonJavaDocker needed?Free allowanceCard at sign-upBehaviour to knowRollback method
Azure App Service F1YesYes (Java SE)NoF1 tier: 60 CPU minutes per day per appYesIdle apps sleep. No deployment slots on F1.Redeploy the previous zip
AWS Elastic Beanstalk, single instanceYesYes (Corretto)NoFree-tier eligible instance hours for new accounts, with limits on the planYesInstance runs all the time, so it uses hours. Stop it when idle.update-environment with an earlier version label
Google App Engine standardYesYes, with a custom entrypointNoDaily free instance hours for F1 instancesYes (billing account)Scales to zero when idle, so cold starts happenMove traffic back to the earlier version
Check the current terms. Free allowances and sign-up rules change. Confirm them on each provider's pricing page on the day, and set a budget alert in each account before you deploy.

Step 1.3 Other free options


Part 2: One smoke test for every target 5 min

Goal: one script verifies any deployment by checking the version and one calculation. The pipelines call it for every target.

Step 2.1 Create the script in both repositories

cd ~/work/python-ci-demo
git checkout main && git pull
git checkout -b cd/any-cloud
mkdir -p scripts
scripts/smoke_test.sh
#!/usr/bin/env bash
set -euo pipefail

BASE_URL="${1:?usage: smoke_test.sh <base-url> <version> [attempts]}"
VERSION="${2:?usage: smoke_test.sh <base-url> <version> [attempts]}"
ATTEMPTS="${3:-20}"

for attempt in $(seq 1 "$ATTEMPTS"); do
  if curl -fsS "$BASE_URL/health" | jq -e --arg v "$VERSION" '.version == $v' > /dev/null; then
    if curl -fsS "$BASE_URL/add?a=2&b=3" | jq -e '.result == 5' > /dev/null; then
      echo "OK: $BASE_URL serves version $VERSION and passes the functional check (attempt $attempt)"
      exit 0
    fi
    echo "FAIL: version $VERSION is live but /add returned the wrong result"
    exit 1
  fi
  echo "attempt $attempt: version $VERSION not live yet"
  sleep 15
done

echo "FAIL: $BASE_URL did not report version $VERSION after $ATTEMPTS attempts"
exit 1
chmod +x scripts/smoke_test.sh
# Local check against the Python dev server (Phase C of this document)
gunicorn --bind 127.0.0.1:8000 app.web:app &
sleep 3
./scripts/smoke_test.sh http://127.0.0.1:8000 1.2.0 3
kill %1

Explanation

Checkpoint: the local run prints OK for version 1.2.0. Copy the script to the Java repository and make it executable there too. The script does not depend on the language of the app.
cd ~/work/java-ci-demo
git checkout main && git pull
git checkout -b cd/any-cloud
mkdir -p scripts
cp ~/work/python-ci-demo/scripts/smoke_test.sh scripts/
chmod +x scripts/smoke_test.sh

Part 3: Cloud-ready entry points 10 min

Step 3.1 Python: Google App Engine configuration

Goal: tell App Engine how to run the Flask app with Gunicorn on the port it assigns.

deploy/gae/app.yaml (python-ci-demo)
runtime: python312
service: default
entrypoint: gunicorn -b :$PORT app.web:app
instance_class: F1
automatic_scaling:
  min_instances: 0
  max_instances: 1
handlers:
  - url: /.*
    script: auto

Explanation

Step 3.2 Java: Google App Engine configuration

Goal: run the released jar on App Engine's Java 17 runtime.

deploy/gae-java/app.yaml (java-ci-demo)
runtime: java17
service: calc-java
entrypoint: java -jar app.jar
instance_class: F1
automatic_scaling:
  min_instances: 0
  max_instances: 1
handlers:
  - url: /.*
    script: auto

Explanation

If the runtime rejects the entrypoint. App Engine's Java runtimes have changed over time. If gcloud app deploy reports that the entrypoint is not supported for java17, check the current Java runtime documentation for the supported way to run a jar. Until it is resolved, keep Java on Azure and AWS and skip the Java App Engine job.

Step 3.3 Commit the configuration

cd ~/work/python-ci-demo
mkdir -p deploy/gae
git add scripts deploy
git commit -m "Smoke test script and App Engine configuration"
git push -u origin cd/any-cloud

cd ~/work/java-ci-demo
mkdir -p deploy/gae-java
git add scripts deploy
git commit -m "Smoke test script and App Engine configuration"
git push -u origin cd/any-cloud

Open pull requests for both branches and merge them after CI passes. Then return to main in both repositories.

Checkpoint: both repositories have scripts/smoke_test.sh and a deploy folder on main.

Part 4: Google Cloud free project and App Engine 15 min

Step 4.1 Sign in and install the SDK

  1. Open cloud.google.com and sign in with a personal Google account.
  2. Install the Google Cloud CLI (gcloud) for your operating system from the official install page.
gcloud init
gcloud auth login
gcloud --version

Explanation

Step 4.2 Create a billing account and link the project

  1. In the console, open Billing and create a billing account. A card is required. Google places a small temporary check on it.
  2. Create a budget under Billing, then Budgets and alerts with a limit of 10 in your currency and alerts at 50 and 90 percent.
gcloud billing accounts list

Note the billing account ID from the output, then create the project. Project IDs must be globally unique, so add a suffix:

export GCP_PROJECT=calc-cd-7421
gcloud projects create "$GCP_PROJECT" --name="calc cd lab"
gcloud billing projects link "$GCP_PROJECT" --billing-account=<billing-account-id>
gcloud config set project "$GCP_PROJECT"

Step 4.3 Create the App Engine application

gcloud services enable appengine.googleapis.com cloudbuild.googleapis.com
gcloud app create --region=us-central

Explanation

gcloud app describe --format="value(defaultHostname)"

The output is the host name of the default service, for example calc-cd-7421.uc.r.appspot.com. The Java service uses the same host with its service name in front: calc-java-dot-calc-cd-7421.uc.r.appspot.com. Save both values for Part 6.

Step 4.4 Create a deployer service account for the pipeline

Goal: give the pipeline a limited identity to deploy with.

gcloud iam service-accounts create ci-deployer --display-name="CI deployer"
SA=ci-deployer@"$GCP_PROJECT".iam.gserviceaccount.com
for ROLE in roles/appengine.deployer roles/appengine.serviceAdmin roles/cloudbuild.builds.editor roles/storage.admin roles/iam.serviceAccountUser roles/serviceusage.serviceUsageConsumer; do
  gcloud projects add-iam-policy-binding "$GCP_PROJECT" --member="serviceAccount:$SA" --role="$ROLE" --condition=None
done
gcloud iam service-accounts keys create gcp-ci-key.json --iam-account="$SA"

Explanation

Key handling. Do not commit gcp-ci-key.json. Delete the local copy after you store it in GitHub. For production, use Workload Identity Federation so no long-lived key exists.
Checkpoint: gcloud app describe returns a host name, and the key file exists locally.

Part 5: Deploy by hand to App Engine 10 min

Before the pipeline runs unattended, deploy each app once by hand. This shows what the pipeline does and exposes configuration problems early.

Step 5.1 Deploy the Python app without routing traffic

mkdir -p ~/work/gae-py && cd ~/work/gae-py
cp ~/work/python-ci-demo/deploy/gae/app.yaml .
printf '%s\n' "calc-ci-demo @ https://github.com/<your-user>/python-ci-demo/releases/download/v1.2.0/calc_ci_demo-1.2.0-py3-none-any.whl" > requirements.txt
gcloud app deploy app.yaml --version 1-2-0 --no-promote --quiet

Explanation

Verify the version directly

PY_HOST=$(gcloud app describe --format="value(defaultHostname)")
curl -s "https://1-2-0-dot-$PY_HOST/health"
~/work/python-ci-demo/scripts/smoke_test.sh "https://1-2-0-dot-$PY_HOST" 1.2.0

Expected output

The health response shows version 1.2.0, and the smoke test prints OK. The version host name follows the pattern VERSION-dot-HOST.

Step 5.2 Send traffic to the verified version

gcloud app services set-traffic default --splits 1-2-0=1 --quiet
curl -s "https://$PY_HOST/health"

The default host now serves version 1.2.0. The traffic command replaces all earlier splits with the one given here.

Step 5.3 Deploy the Java app the same way

mkdir -p ~/work/gae-java && cd ~/work/gae-java
curl -fsSL -o app.jar "https://github.com/<your-user>/java-ci-demo/releases/download/v1.2.0/calc-ci-demo-1.2.0.jar"
cp ~/work/java-ci-demo/deploy/gae-java/app.yaml .
gcloud app deploy app.yaml --version 1-2-0 --no-promote --quiet

JAVA_HOST="calc-java-dot-$PY_HOST"
~/work/java-ci-demo/scripts/smoke_test.sh "https://1-2-0-dot-$JAVA_HOST" 1.2.0
gcloud app services set-traffic calc-java --splits 1-2-0=1 --quiet

Expected output

The smoke test prints OK for the Java version, and traffic moves to 1-2-0 for the calc-java service. If the Java deploy fails at startup, check gcloud app logs tail -s calc-java.

Checkpoint: both services answer on their default host names with version 1.2.0. You have now checked the App Engine configuration by hand.

Part 6: Secrets, variables, and environments 10 min

Step 6.1 Set the values in GitHub

In each repository, open Settings, then Secrets and variables, then Actions. Set the following values. Secrets are write-only after you save them. Variables are visible to anyone with read access to the repository.

RepositoryTypeNameValue
BothVariableGCP_PROJECTYour project ID, for example calc-cd-7421
BothSecretGCP_SA_KEYThe full contents of gcp-ci-key.json
python-ci-demoVariableGCP_PY_HOSTThe default host from gcloud app describe, without https
java-ci-demoVariableGCP_JAVA_HOSTcalc-java-dot- followed by the default host, without https
BothSecretAWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEYThe ci-deployer keys from Step D2
BothVariableAWS_REGIONus-east-1
python-ci-demoVariableAWS_PY_URLElastic Beanstalk CNAME of calc-py-env, without http
java-ci-demoVariableAWS_JAVA_URLElastic Beanstalk CNAME of calc-java-env, without http
python-ci-demoVariable and secretAZURE_PY_APP, AZURE_PY_HOST, AZURE_PY_PUBLISH_PROFILEFrom Step D1 (publish profile file)
java-ci-demoVariable and secretAZURE_JAVA_APP, AZURE_JAVA_HOST, AZURE_JAVA_PUBLISH_PROFILEFrom the Azure Java web app

Step 6.2 Create the environments

  1. In each repository, open Settings, then Environments.
  2. Create three environments: azure-free, aws-free, and gcp-free. Each deploy job names its environment, so the environment also holds the approval rule for that target.
  3. Optional: add yourself as a required reviewer on gcp-free. The Google deploy then waits for approval before it runs.
Keeping each target in its own environment means you can pause one cloud without stopping the others, and you can see the deploy history per target.
Checkpoint: both repositories list the variables and secrets above, and both have the three environments.

Part 7: The Python release and deploy pipeline 15 min

Replace .github/workflows/release.yml in python-ci-demo with the file below. The build and release jobs are the same as before. Three deploy jobs follow, one per target, and each finishes with the shared smoke test.

.github/workflows/release.yml
name: Release and Deploy

on:
  push:
    tags: ["v*.*.*"]

permissions:
  contents: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout tagged commit
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt build

      - name: Test before publishing
        run: pytest

      - name: Check versions match tag
        run: ./scripts/check_version.sh "$GITHUB_REF_NAME"

      - name: Build wheel and sdist
        run: python -m build

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Pack npm tarball
        run: npm pack --pack-destination ../dist
        working-directory: npm

      - name: Write checksums
        working-directory: dist
        run: sha256sum * > SHA256SUMS.txt

      - name: Upload distribution
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  release:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download distribution
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: dist/*
          generate_release_notes: true
          prerelease: ${{ contains(github.ref_name, '-') }}

  deploy-azure:
    needs: release
    runs-on: ubuntu-latest
    environment: azure-free
    steps:
      - name: Checkout (for the smoke test script)
        uses: actions/checkout@v4

      - name: Create zip bundle that pins the released wheel
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          printf '%s\n' "calc-ci-demo @ https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/calc_ci_demo-${VERSION}-py3-none-any.whl" > requirements.txt
          zip deploy-azure.zip requirements.txt

      - name: Deploy to Azure App Service
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ vars.AZURE_PY_APP }}
          publish-profile: ${{ secrets.AZURE_PY_PUBLISH_PROFILE }}
          package: deploy-azure.zip

      - name: Smoke test Azure
        run: ./scripts/smoke_test.sh "https://${{ vars.AZURE_PY_HOST }}" "${GITHUB_REF_NAME#v}"

  deploy-aws:
    needs: release
    runs-on: ubuntu-latest
    environment: aws-free
    steps:
      - name: Checkout (for the smoke test script)
        uses: actions/checkout@v4

      - name: Create Elastic Beanstalk bundle that pins the released wheel
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          printf '%s\n' "calc-ci-demo @ https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/calc_ci_demo-${VERSION}-py3-none-any.whl" > requirements.txt
          printf '%s\n' "from app.web import app as application" > application.py
          zip bundle-aws.zip requirements.txt application.py

      - name: Deploy to Elastic Beanstalk
        uses: einaregilsson/beanstalk-deploy@v22
        with:
          aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          region: ${{ vars.AWS_REGION }}
          application_name: calc-py-app
          environment_name: calc-py-env
          version_label: ${{ github.ref_name }}
          deployment_package: bundle-aws.zip

      - name: Smoke test AWS
        run: ./scripts/smoke_test.sh "http://${{ vars.AWS_PY_URL }}" "${GITHUB_REF_NAME#v}"

  deploy-gcp:
    needs: release
    runs-on: ubuntu-latest
    environment: gcp-free
    steps:
      - name: Checkout (App Engine config and smoke test)
        uses: actions/checkout@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v2

      - name: Build App Engine bundle that pins the released wheel
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          mkdir -p gae-bundle
          cp deploy/gae/app.yaml gae-bundle/
          printf '%s\n' "calc-ci-demo @ https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/calc_ci_demo-${VERSION}-py3-none-any.whl" > gae-bundle/requirements.txt

      - name: Deploy version without traffic
        run: |
          VID=$(echo "${GITHUB_REF_NAME#v}" | tr '.' '-')
          gcloud app deploy gae-bundle/app.yaml --version "$VID" --no-promote --quiet --project "${{ vars.GCP_PROJECT }}"

      - name: Smoke test the new version before it receives traffic
        run: |
          VID=$(echo "${GITHUB_REF_NAME#v}" | tr '.' '-')
          ./scripts/smoke_test.sh "https://$VID-dot-${{ vars.GCP_PY_HOST }}" "${GITHUB_REF_NAME#v}"

      - name: Promote the verified version to all traffic
        run: |
          VID=$(echo "${GITHUB_REF_NAME#v}" | tr '.' '-')
          gcloud app services set-traffic default --splits "$VID=1" --project "${{ vars.GCP_PROJECT }}" --quiet

Explanation: how the file is organised

PartWhy it is written this way
build and releaseUnchanged from the artifacts session. Every target receives the same files from this one release.
Three deploy jobs with needs: releaseThey run in parallel after the release exists. One failing target does not block the others, but each shows its own result.
environment per jobEach target has its own secrets scope and approval rule.
Bundles that pin the wheelEach target installs the released wheel by URL. Azure and AWS install it through requirements.txt, and App Engine does the same from its bundle.
smoke_test.sh for every targetThe same definition of "healthy" applies everywhere: the version and the calculation both pass.
Version check in the build jobA mismatched tag fails before anything is published. The release is never created from a broken version.
App Engine: deploy, verify, then promoteThe new version gets its own address. The smoke test runs against that address, and only a passing version receives traffic.
VID conversion with trApp Engine version IDs cannot contain dots, so 1.2.0 becomes 1-2-0.
The deploy jobs do not call each other. Azure and AWS are verified in their own jobs, and the GCP job alone controls traffic, because only App Engine splits traffic in this lab. Azure F1 and Elastic Beanstalk single instances have a single live version each.
git checkout -b cd/pipeline-python
git add .github/workflows/release.yml
git commit -m "Release pipeline: deploy to Azure, AWS, and App Engine with shared smoke test"
git push -u origin cd/pipeline-python

Open a pull request, let CI pass, and merge it into main.


Part 8: The Java release and deploy pipeline 15 min

Replace .github/workflows/release.yml in java-ci-demo. The release job is unchanged in purpose: it verifies, packages, and publishes the jar. The three deploy jobs use the released jar.

.github/workflows/release.yml
name: Java Release and Deploy

on:
  push:
    tags: ["v*.*.*"]

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout tagged commit
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
          cache: maven

      - name: Check pom.xml version matches tag
        run: |
          POM_VERSION=$(sed -n 's:.*<version>\(.*\)</version>.*:\1:p' pom.xml | head -1)
          [ "$POM_VERSION" = "${GITHUB_REF_NAME#v}" ] || { echo "pom.xml is $POM_VERSION, tag is $GITHUB_REF_NAME"; exit 1; }

      - name: Verify
        run: mvn -B verify

      - name: Package jar
        run: mvn -B -DskipTests package

      - name: Write checksum
        working-directory: target
        run: sha256sum calc-ci-demo-*.jar > SHA256SUM-jar.txt

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: |
            target/calc-ci-demo-*.jar
            target/SHA256SUM-jar.txt
          generate_release_notes: true
          prerelease: ${{ contains(github.ref_name, '-') }}

  deploy-azure:
    needs: release
    runs-on: ubuntu-latest
    environment: azure-free
    steps:
      - name: Checkout (for the smoke test script)
        uses: actions/checkout@v4

      - name: Download the released jar into a zip
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          curl -fsSL -o app.jar "https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/calc-ci-demo-${VERSION}.jar"
          zip deploy-azure.zip app.jar

      - name: Deploy to Azure App Service
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ vars.AZURE_JAVA_APP }}
          publish-profile: ${{ secrets.AZURE_JAVA_PUBLISH_PROFILE }}
          package: deploy-azure.zip

      - name: Smoke test Azure
        run: ./scripts/smoke_test.sh "https://${{ vars.AZURE_JAVA_HOST }}" "${GITHUB_REF_NAME#v}"

  deploy-aws:
    needs: release
    runs-on: ubuntu-latest
    environment: aws-free
    steps:
      - name: Checkout (for the smoke test script)
        uses: actions/checkout@v4

      - name: Download the released jar into a bundle
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          curl -fsSL -o application.jar "https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/calc-ci-demo-${VERSION}.jar"
          zip bundle-aws.zip application.jar

      - name: Deploy to Elastic Beanstalk
        uses: einaregilsson/beanstalk-deploy@v22
        with:
          aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          region: ${{ vars.AWS_REGION }}
          application_name: calc-java-app
          environment_name: calc-java-env
          version_label: ${{ github.ref_name }}
          deployment_package: bundle-aws.zip

      - name: Smoke test AWS
        run: ./scripts/smoke_test.sh "http://${{ vars.AWS_JAVA_URL }}" "${GITHUB_REF_NAME#v}"

  deploy-gcp:
    needs: release
    runs-on: ubuntu-latest
    environment: gcp-free
    steps:
      - name: Checkout (App Engine config and smoke test)
        uses: actions/checkout@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v2

      - name: Build App Engine bundle from the released jar
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          mkdir -p gae-bundle
          cp deploy/gae-java/app.yaml gae-bundle/
          curl -fsSL -o gae-bundle/app.jar "https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/calc-ci-demo-${VERSION}.jar"

      - name: Deploy version without traffic
        run: |
          VID=$(echo "${GITHUB_REF_NAME#v}" | tr '.' '-')
          gcloud app deploy gae-bundle/app.yaml --version "$VID" --no-promote --quiet --project "${{ vars.GCP_PROJECT }}"

      - name: Smoke test the new version before it receives traffic
        run: |
          VID=$(echo "${GITHUB_REF_NAME#v}" | tr '.' '-')
          ./scripts/smoke_test.sh "https://$VID-dot-${{ vars.GCP_JAVA_HOST }}" "${GITHUB_REF_NAME#v}"

      - name: Promote the verified version to all traffic
        run: |
          VID=$(echo "${GITHUB_REF_NAME#v}" | tr '.' '-')
          gcloud app services set-traffic calc-java --splits "$VID=1" --project "${{ vars.GCP_PROJECT }}" --quiet
git checkout -b cd/pipeline-java
git add .github/workflows/release.yml
git commit -m "Release pipeline: deploy the released jar to Azure, AWS, and App Engine"
git push -u origin cd/pipeline-java

Open a pull request, let CI pass, and merge it into main.

Java on App Engine is the least certain target. If the Java App Engine job fails for runtime reasons, keep the job, mark it as known failing in your notes, and continue with Azure and AWS. Do not remove the verification step to make it pass.
Checkpoint: both workflows on main have three deploy jobs, and the release job for each is unchanged in what it verifies.

Part 9: Release 1.2.1 to every target 10 min

Step 9.1 Bump the version in all three files

Set the version to 1.2.1 in pyproject.toml, npm/package.json, and the project <version> in pom.xml. Then check both repositories:

cd ~/work/python-ci-demo
git checkout main && git pull
git checkout -b release/1.2.1
# edit pyproject.toml and npm/package.json to 1.2.1
git add . && git commit -m "Version 1.2.1"
git push -u origin release/1.2.1
# open a pull request, merge it, then:
git checkout main && git pull
./scripts/check_version.sh v1.2.1

Repeat for java-ci-demo with pom.xml, and confirm the Maven version reads 1.2.1.

Step 9.2 Tag the release

cd ~/work/python-ci-demo
git tag -a v1.2.1 -m "Release 1.2.1"
git push origin v1.2.1

cd ~/work/java-ci-demo
git tag -a v1.2.1 -m "Release 1.2.1"
git push origin v1.2.1

Step 9.3 Follow the run

  1. In each repository, open the Actions run for v1.2.1.
  2. Confirm build and release pass, then the three deploy jobs run.
  3. In the GCP job, confirm the order: deploy without traffic, smoke test the version URL, then promote.
  4. Open the Summary of each run and check that every smoke test printed OK with version 1.2.1.
Checkpoint: six deploy jobs in total (three per repository) pass, and every target reports version 1.2.1.

Part 10: Access the endpoints 10 min

Step 10.1 Collect the host name for each target

PY_AZURE=<your-azure-python-host>.azurewebsites.net
JAVA_AZURE=<your-azure-java-host>.azurewebsites.net
PY_AWS=$(eb status calc-py-env 2>/dev/null | awk '/CNAME/ {print $2}')
JAVA_AWS=$(eb status calc-java-env 2>/dev/null | awk '/CNAME/ {print $2}')
PY_GCP=$(gcloud app describe --format="value(defaultHostname)")
JAVA_GCP="calc-java-dot-$PY_GCP"

Run the eb status lines from the Elastic Beanstalk project folders if the CLI needs the environment name from that folder.

Step 10.2 Call the same requests on every target

for URL in "https://$PY_AZURE" "https://$JAVA_AZURE" "http://$PY_AWS" "http://$JAVA_AWS" "https://$PY_GCP" "https://$JAVA_GCP"; do
  echo "== $URL"
  curl -s "$URL/health"; echo
  curl -s "$URL/add?a=2&b=3"; echo
  curl -s "$URL/divide?a=10&b=4"; echo
done

Expected output

Every target returns the same JSON shape. Each /health reports version 1.2.1, /add returns a result of 5 (or 5.0 for Python), and /divide returns 2.5.

Step 10.3 Read the logs for each target

TargetCommand
Azure App Serviceaz webapp log tail --name <app> --resource-group cd-lab-rg
Elastic Beanstalkeb logs calc-py-env (from the project folder)
App Enginegcloud app logs tail -s default and gcloud app logs tail -s calc-java

Step 10.4 Observe the cold starts

Leave the apps idle for about 20 minutes, then time the first request to each. Azure F1 and App Engine both scale down when idle, so the first response is slow. Elastic Beanstalk keeps its instance running and is usually fast.


Part 11: Roll back each target 15 min

Each target has a different rollback mechanism. Practise one per target. The rollback target is version 1.2.0, which is still available on each platform.

Step 11.1 Azure: redeploy the previous zip

mkdir -p ~/work/rollback-azure && cd ~/work/rollback-azure
printf '%s\n' "calc-ci-demo @ https://github.com/<your-user>/python-ci-demo/releases/download/v1.2.0/calc_ci_demo-1.2.0-py3-none-any.whl" > requirements.txt
zip rollback.zip requirements.txt
az webapp deploy --resource-group cd-lab-rg --name <your-azure-python-app> --src-path rollback.zip --type zip
sleep 120
~/work/python-ci-demo/scripts/smoke_test.sh "https://$PY_AZURE" 1.2.0

Explanation

Step 11.2 AWS: point the environment at the earlier version label

aws elasticbeanstalk update-environment \
  --environment-name calc-py-env \
  --version-label v1.2.0 \
  --region us-east-1 \
  --profile ci-deployer

aws elasticbeanstalk wait environment-updated \
  --environment-names calc-py-env \
  --region us-east-1 \
  --profile ci-deployer

~/work/python-ci-demo/scripts/smoke_test.sh "http://$PY_AWS" 1.2.0

Explanation

Step 11.3 Google App Engine: move traffic back

gcloud app services set-traffic default --splits 1-2-0=1 --project "$GCP_PROJECT" --quiet
~/work/python-ci-demo/scripts/smoke_test.sh "https://$PY_GCP" 1.2.0

Explanation

Step 11.4 Record the rollback

  1. Open an issue or a note in the repository with the tag that failed, the target, the rollback command, and the time it took.
  2. Tag a fix as a new patch version. Do not move or reuse the failed tag.
Checkpoint: the Python app on each target can return to 1.2.0 and passes the smoke test, and you have recorded the steps.

Part 12: Cleanup, recap, and troubleshooting 15 min

Step 12.1 Remove the resources in each cloud

# Azure
az group delete --name cd-lab-rg --yes --no-wait

# AWS (run from each Elastic Beanstalk project folder, then delete the applications)
eb terminate calc-py-env --force
eb terminate calc-java-env --force
aws elasticbeanstalk delete-application --application-name calc-py-app --terminate-env-by-force --region us-east-1 --profile ci-deployer
aws elasticbeanstalk delete-application --application-name calc-java-app --terminate-env-by-force --region us-east-1 --profile ci-deployer

# Google Cloud: remove the services, or the whole project
gcloud app services delete calc-java --quiet
gcloud projects delete "$GCP_PROJECT" --quiet

Explanation

Step 12.2 Remove the credentials

  1. Delete the ci-deployer access key in AWS IAM, then delete the user.
  2. Delete the service account key in Google Cloud, then delete gcp-ci-key.json from your machine.
  3. In GitHub, remove the secrets and variables from both repositories, and delete the three environments if you will not reuse them.

Step 12.3 Recap

Step 12.4 Quiz

  1. Why does every deploy job use the same smoke test script?
  2. Why does the App Engine job deploy with --no-promote and promote in a later step?
  3. Why does the version ID become 1-2-0 and not 1.2.0?
  4. Azure F1 has no deployment slots. How is a rollback done there instead, and why does it still keep the exact bytes of the earlier release?
Answers
  1. So that every target has the same definition of healthy. Otherwise a deployment could pass on one cloud and be broken on another without anyone noticing.
  2. The new version can be verified at its own address before it receives any users. Promotion is the only step that changes what users see.
  3. App Engine version IDs allow only lowercase letters, digits, and hyphens. Dots are not allowed.
  4. It redeploys a zip that pins the earlier wheel URL. The wheel stays on its GitHub Release, so installing it again gives the same files.

Step 12.5 Troubleshooting

SymptomCause and fix
Smoke test prints not live yet until it failsRead the target's logs (Part 10.3). The new version may have failed to start, or the variable with the host name may be wrong.
smoke_test.sh: Permission deniedRun chmod +x scripts/smoke_test.sh and commit the file mode.
Google deploy fails with PERMISSION_DENIEDThe error names the missing role. Add that role to ci-deployer, then re-run the job.
App Engine version ID rejectedVersion IDs must be lowercase letters, digits, and hyphens. The tr '.' '-' step handles dots; check any manual name you typed.
GCP version host returns 404The version URL must be VERSION-dot-HOST, and for Java VERSION-dot-calc-java-dot-PROJECT.... Check GCP_PY_HOST and GCP_JAVA_HOST.
Java on App Engine fails at startupCheck the runtime's current documentation for running a jar through the entrypoint. Read the logs with gcloud app logs tail -s calc-java.
Elastic Beanstalk environment Red after the deploy jobRead eb logs. Check that the bundle's requirements name the wheel for the correct tag.
Azure job fails with 401The publish profile is outdated. Download it again and update the secret.
Deploy job waits foreverThe environment has a required reviewer. Approve the deployment in the Actions run, or remove the rule for this lab.
Budget alert on Google CloudCheck the App Engine instance hours on the billing page. Set max_instances: 1 and delete unused versions.
Release created but a deploy job was skippedThe deploy jobs need the release job to succeed. Read the release job log first.