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.
- End-to-end runbook (start here)
- Session plan and prerequisites
- Part 1: Continuous delivery design and free targets
- Part 2: One smoke test for every target
- Part 3: Cloud-ready entry points
- Part 4: Google Cloud free project and App Engine
- Part 5: Deploy by hand to App Engine
- Part 6: Secrets, variables, and environments
- Part 7: The Python release and deploy pipeline
- Part 8: The Java release and deploy pipeline
- Part 9: Release 1.2.1 to every target
- Part 10: Access the endpoints
- Part 11: Roll back each target
- 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
| Phase | What you do | Where | Time |
|---|---|---|---|
| A. Accounts | Create Azure, AWS, and Google Cloud accounts with budget alerts | Phase A below | 25 min, plus verification waits |
| B. Tools | Install and sign in to the CLIs | Phase B below | 10 min |
| C. Endpoint code | Add /health, /add, and /divide to both apps and commit | Phase C below | 15 min |
| E. Smoke test and App Engine config | Create the shared smoke test script and the App Engine files | Parts 2 and 3 | 10 min |
| D. Cloud resources | Create the Azure apps, the AWS deployer user and Elastic Beanstalk environments, and the Google project | Phase D below | 25 min |
| F. Pipelines and secrets | Add the release workflows, then set variables, secrets, and environments from the command line | Phase F below, with Parts 6, 7, and 8 | 15 min |
| G. First release | Tag v1.2.0 in both repositories so all three targets deploy | Phase G below | 10 min |
| H. Second release and rollback | Release 1.2.1 and roll back one target | Parts 9, 10, and 11 | 25 min |
| I. Cleanup | Remove resources in all three clouds | Part 12 | 10 min |
Phase A: Accounts About 60 min including verification waits
Before you start
| Item | Why it matters |
|---|---|
| A personal email address for each provider | Work accounts may block lab resources. Keep the lab under your own control. |
| A mobile phone that can receive SMS and voice calls | Every provider verifies the phone during sign-up. |
| A credit or debit card in your name | Required for the standard free account on all three providers. |
| International or online card transactions enabled | Many Indian-issued cards are declined without this. Enable it in your bank's app before you start. |
| An authenticator app on your phone | Used for MFA on each account. Any TOTP app works, such as Microsoft Authenticator or Google Authenticator. |
| A password manager | You will store three root passwords and several access keys. |
Step A1 Azure account
- Start sign-up. Open
azure.microsoft.com/freein a private browser window. Choose Start free (the label may read Try for free). - 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.
- 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.
- Accept the agreements. Tick the subscription agreement and offer statement boxes, then choose Next.
- 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.
- 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.
- Finish and open the portal. Complete the final screen and open
portal.azure.com. - 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.
- Turn on MFA. Open
aka.ms/mfasetupand 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. - 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 to10in 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. - Check from the command line. After Phase B installs the Azure CLI, run
az login, thenaz account show --output table.
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
- Start sign-up. Open
aws.amazon.comin a private browser window and choose Create an AWS account. - 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. - Set the root password. Use a password of at least 16 characters, stored in your password manager. Choose Continue.
- 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.
- Verify the phone. Choose Text message (SMS) or Voice call, complete the CAPTCHA, and enter the code you receive.
- Select the support plan. Choose Basic support (free).
- Add the card. Enter the card and billing address. AWS places a small temporary authorisation to check the card, which is reversed.
- Complete identity verification. AWS may call the phone number and read out a code. Enter it when prompted.
- 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.
- Turn on MFA for the root user. Sign in at
console.aws.amazon.comas 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. - 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.
- Create the administrator user. Open the IAM service and choose Users, then Create user. Name it
admin-youand 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 selectAdministratorAccess. Create the user. - Turn on MFA for the admin user. Open the
admin-youuser, choose Security credentials, then Assign MFA device. Register a passkey or an authenticator app. - 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.
- 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.
- 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 to10. 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. - 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.
- Sign in as the admin user. Sign out of root, open the account sign-in URL from step 14, and sign in as
admin-youwith MFA. Use this user for all later work.
Step A3 Google Cloud account and billing
- Turn on 2-Step Verification first. Open
myaccount.google.comwith 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. - Start the free trial. Open
cloud.google.com, choose Get started for free or Try for free, and sign in. - Enter the account details. Choose your country, select Individual as the account type, enter your name and address, and accept the terms.
- 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.
- 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.
- 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 to10in your currency. Set alert thresholds at 50, 90, and 100 percent, and enter your email address for notifications. - 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.
- Check the trial status. Open Billing and confirm the free trial is shown as active, with its remaining credit and end date.
Phase A check
- Azure:
az loginworks after Phase B, MFA is enabled, andlab-guardexists. - AWS: you can sign in as
admin-youwith MFA, root has MFA and no access keys, and both budgets exist. - Google Cloud: 2-Step Verification is on, the billing account is active, and
lab-guardexists.
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.
| Tool | macOS (Homebrew) | Ubuntu or Debian | Windows (winget) |
|---|---|---|---|
| Azure CLI | brew install azure-cli | Follow Microsoft's Linux install page | winget install Microsoft.AzureCLI |
| AWS CLI v2 | brew install awscli | Download the v2 bundle from AWS and run its installer | winget install Amazon.AWSCLI |
| Google Cloud CLI | brew install --cask google-cloud-sdk | Follow the Google Cloud CLI install page for Debian or Ubuntu | winget install Google.CloudSDK |
| GitHub CLI | brew install gh | Follow the GitHub CLI install page for Linux | winget install GitHub.cli |
| jq | brew install jq | sudo apt-get install -y jq | winget install jqlang.jq |
| zip and unzip | Included | sudo apt-get install -y zip unzip | Use Git Bash, which includes them |
| Python 3.11 or later | brew install python | sudo apt-get install -y python3 python3-venv python3-pip | winget install Python.Python.3.12 |
| JDK 17 and Maven | brew install openjdk@17 maven | sudo apt-get install -y openjdk-17-jdk maven | winget install EclipseAdoptium.Temurin.17.JDK and winget install Apache.Maven |
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
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
- Run
az login. A browser window opens. Sign in with the Microsoft account from Phase A1 and complete the MFA prompt. - If no browser is available (for example, over SSH), run
az login --use-device-codeand enter the code atmicrosoft.com/devicelogin. - List your subscriptions with
az account list --output table. - Choose the free subscription with
az account set --subscription "<subscription-name-or-id>". - Confirm with
az account show --output table. The default subscription should be the free one.
az login again.Step B5 Sign in to AWS
- Create an access key for the admin user. Sign in to the console as
admin-you. Open IAM, then Users, thenadmin-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. - Configure the profile. Run
aws configure --profile admin-you. Enter the key ID and secret, the default regionus-east-1, and the output formatjson. - Check the identity. Run
aws sts get-caller-identity --profile admin-you. The Arn should end inuser/admin-you. - Remove the CSV. If you downloaded a CSV, delete the file after the profile works.
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
- Run
gcloud init. Choose Log in with a new account, and complete the browser sign-in and 2-Step Verification. - 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.
- Choose a default region, such as
us-central1. The App Engine region is set separately in Phase D3. - Run
gcloud auth loginif you need to sign in again later, andgcloud config listto confirm the account and project. - Check billing access with
gcloud billing accounts list. The billing account from Phase A3 should appear withOPENstatus.
Step B7 Sign in to GitHub with the GitHub CLI
- Run
gh auth login. - Choose GitHub.com, then HTTPS as the preferred protocol, and answer yes to authenticate Git with your GitHub credentials.
- 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.
- Check the result with
gh auth status. It should show your account and the token scopes. - The lab sets secrets, variables, and environments, which needs the
repoandworkflowscopes. If they are missing, rungh auth refresh -s workflowand approve the request in the browser. - 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 beADMIN. Environments and secrets cannot be changed with lower permissions.
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)"
Troubleshooting Phase A and Phase B
| Symptom | Fix |
|---|---|
| Card declined at any provider | Check 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 arrives | Wait 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 disabled | The 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 verification | Wait 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 installing | Open a new terminal. On Windows, check that the Azure CLI folder is on the path. |
aws sts get-caller-identity says invalid credentials | Check 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 time | Run gcloud config set project <project-id>, or set it in Phase D3. |
gh api user returns 401 | Run gh auth login again, or gh auth refresh if only scopes are missing. |
| Permission denied when setting environments later | Check viewerPermission in Step B7. You need admin rights on the repository. |
Phase C: Endpoint code 15 min
Step C1 Python: packaging, web module, and tests
cd ~/work/python-ci-demo
git checkout main && git pull
git checkout -b feat/endpoints
[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*"]
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
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'])"
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
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.
- Set the project version: change the
<version>directly under<artifactId>calc-ci-demo</artifactId>to1.2.0. - Replace the
<configuration>ofmaven-jar-pluginwith:
<configuration>
<archive>
<manifest>
<mainClass>com.example.calc.Server</mainClass>
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
</manifest>
</archive>
</configuration>
- In the
jacoco-maven-pluginblock, 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
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
- If the plan fails with a quota of zero, change
LOCATIONto another region and create a new resource group. - The two publish profile files are credentials. They are stored in GitHub in Phase F and then deleted from this folder.
- Runtime names come from
az webapp list-runtimes --os linux. Use the exact string it shows if the create command rejects these.
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
/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
- Choose the App Engine region carefully. It cannot be changed after
gcloud app create. - The default host name is the value stored in
GCP_PY_HOST. The Java service host iscalc-java-dot-followed by the same name. - If a deploy later fails with a permission error, the message names the role to add. Add only that role.
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
read -rsreads the AWS secret without echoing it, andprintfsends it to the CLI through standard input.- Variables are visible to repository readers. Secrets are write-only after they are saved.
- The AWS and GCP values are looked up from the tools rather than typed, which avoids copy errors.
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'
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:
buildandreleasepass, and the release lists the wheel, sdist, npm tarball, and checksums (Python) or the jar and checksum (Java).- Three deploy jobs run: Azure, AWS, and Google Cloud. Each ends with a smoke test line that reads
OK: ... serves version 1.2.0. - In the Google Cloud job, the version is deployed without traffic, verified, and only then promoted.
/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
| Time | Part | What you finish |
|---|---|---|
| 0:00 to 0:10 | 1. Design and free targets | How one release reaches several clouds |
| 0:10 to 0:20 | 2 and 3. Smoke test and entry points | One verification script and cloud-ready apps |
| 0:20 to 0:35 | 4. Google Cloud project | Free project, App Engine enabled, CI service account |
| 0:35 to 0:45 | 5. Manual App Engine deploy | Python and Java deployed by hand, once |
| 0:45 to 0:55 | 6. Secrets and environments | All variables and secrets set in both repositories |
| 0:55 to 1:10 | 7 and 8. Pipelines | Release workflows with three deploy targets each |
| 1:10 to 1:20 | 9. Release 1.2.1 | Every target updated from one tag |
| 1:20 to 1:30 | 10 and 11. Access and rollback | Endpoints checked, one target rolled back |
| 1:30 to 2:00 | 12. Cleanup and recap | Resources removed in all three clouds |
Prerequisites
| Requirement | How to check |
|---|---|
Tag v1.2.0 in python-ci-demo and java-ci-demo, with the endpoints | Releases page of each repository |
| Azure App Service F1 plan and two web apps, created in Phase D1 | az webapp list --output table |
| Two Elastic Beanstalk environments, created in Phase D2 | eb 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, zip | gcloud --version, jq --version |
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
- Build once. Every target receives the same files from the GitHub Release. The targets never build from source.
- One contract. Every target exposes
/healthwith the version and/addfor a functional check. The same script verifies all of them. - Verify, then route. App Engine can deploy a version without sending it traffic, so a bad release is never served to users.
Step 1.2 Compare the free targets
| Target | Python | Java | Docker needed? | Free allowance | Card at sign-up | Behaviour to know | Rollback method |
|---|---|---|---|---|---|---|---|
| Azure App Service F1 | Yes | Yes (Java SE) | No | F1 tier: 60 CPU minutes per day per app | Yes | Idle apps sleep. No deployment slots on F1. | Redeploy the previous zip |
| AWS Elastic Beanstalk, single instance | Yes | Yes (Corretto) | No | Free-tier eligible instance hours for new accounts, with limits on the plan | Yes | Instance runs all the time, so it uses hours. Stop it when idle. | update-environment with an earlier version label |
| Google App Engine standard | Yes | Yes, with a custom entrypoint | No | Daily free instance hours for F1 instances | Yes (billing account) | Scales to zero when idle, so cold starts happen | Move traffic back to the earlier version |
Step 1.3 Other free options
- Render (free web service): good for Python, sleeps when idle. Java usually needs a container, which this session avoids.
- PythonAnywhere (free account): Python web apps with a fixed host name and no card. Deployment is manual through its web console or a git pull, so it does not fit a tag-driven pipeline without extra work.
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
#!/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
BASE_URLis the root of the app, with no trailing slash. Each target supplies a different base URL.- The script checks the version first. A deployment that is up but still running the old version fails, which is the point of the check.
jq -esets the exit status from the expression, so the script fails when the value does not match.- The retry loop covers the delay while a platform builds or starts the new version.
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.
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
entrypointis the command App Engine runs.$PORTis set by App Engine, and Gunicorn binds to it.instance_class: F1is the smallest instance and the one covered by the free daily hours.max_instances: 1prevents scale-out, so a traffic spike cannot use more free hours than the allowance.min_instances: 0lets the app scale to zero, which saves hours but adds a cold start.
Step 3.2 Java: Google App Engine configuration
Goal: run the released jar on App Engine's Java 17 runtime.
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
service: calc-javagives the Java app its own service, so it does not replace the Python app on the default service. Its host name includes the service name.entrypoint: java -jar app.jarruns the released jar. The Java server readsPORT, which App Engine sets to 8080, so no port change is needed here.
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.
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
- Open
cloud.google.comand sign in with a personal Google account. - Install the Google Cloud CLI (
gcloud) for your operating system from the official install page.
gcloud init
gcloud auth login
gcloud --version
Explanation
gcloud initsets a default account and project for the terminal.gcloud auth loginopens a browser to sign in and stores the credentials locally.
Step 4.2 Create a billing account and link the project
- In the console, open Billing and create a billing account. A card is required. Google places a small temporary check on it.
- 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
- Enabling services turns on the App Engine API and Cloud Build. Cloud Build installs the Python and Java dependencies when you deploy.
- The region cannot be changed later.
us-centralis a common choice. Choose a region close to your users before you run this step.
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
- The roles cover deploying to App Engine, traffic routing, the Cloud Build step, the storage bucket that holds uploads, and service account use. If a deploy fails with a permission error, the message names the role that is missing. Add only that role.
- The JSON key is a credential. It goes into a GitHub secret in Part 6, and the local copy is deleted after that.
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.gcloud app describe returns a host name, and the key file exists locally.Part 5: Deploy by hand to App Engine 10 min
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
--version 1-2-0names this deployment. App Engine version IDs allow lowercase letters, digits, and hyphens, so the dots from the tag are replaced with hyphens.--no-promotedeploys the version without sending it traffic. This is the step that allows verification before users see the change.
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.
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.
| Repository | Type | Name | Value |
|---|---|---|---|
| Both | Variable | GCP_PROJECT | Your project ID, for example calc-cd-7421 |
| Both | Secret | GCP_SA_KEY | The full contents of gcp-ci-key.json |
| python-ci-demo | Variable | GCP_PY_HOST | The default host from gcloud app describe, without https |
| java-ci-demo | Variable | GCP_JAVA_HOST | calc-java-dot- followed by the default host, without https |
| Both | Secret | AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY | The ci-deployer keys from Step D2 |
| Both | Variable | AWS_REGION | us-east-1 |
| python-ci-demo | Variable | AWS_PY_URL | Elastic Beanstalk CNAME of calc-py-env, without http |
| java-ci-demo | Variable | AWS_JAVA_URL | Elastic Beanstalk CNAME of calc-java-env, without http |
| python-ci-demo | Variable and secret | AZURE_PY_APP, AZURE_PY_HOST, AZURE_PY_PUBLISH_PROFILE | From Step D1 (publish profile file) |
| java-ci-demo | Variable and secret | AZURE_JAVA_APP, AZURE_JAVA_HOST, AZURE_JAVA_PUBLISH_PROFILE | From the Azure Java web app |
Step 6.2 Create the environments
- In each repository, open Settings, then Environments.
- Create three environments:
azure-free,aws-free, andgcp-free. Each deploy job names its environment, so the environment also holds the approval rule for that target. - Optional: add yourself as a required reviewer on
gcp-free. The Google deploy then waits for approval before it runs.
Part 7: The Python release and deploy pipeline 15 min
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
| Part | Why it is written this way |
|---|---|
build and release | Unchanged from the artifacts session. Every target receives the same files from this one release. |
Three deploy jobs with needs: release | They run in parallel after the release exists. One failing target does not block the others, but each shows its own result. |
environment per job | Each target has its own secrets scope and approval rule. |
| Bundles that pin the wheel | Each 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 target | The same definition of "healthy" applies everywhere: the version and the calculation both pass. |
| Version check in the build job | A mismatched tag fails before anything is published. The release is never created from a broken version. |
| App Engine: deploy, verify, then promote | The new version gets its own address. The smoke test runs against that address, and only a passing version receives traffic. |
VID conversion with tr | App Engine version IDs cannot contain dots, so 1.2.0 becomes 1-2-0. |
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
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.
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
- In each repository, open the Actions run for
v1.2.1. - Confirm
buildandreleasepass, then the three deploy jobs run. - In the GCP job, confirm the order: deploy without traffic, smoke test the version URL, then promote.
- Open the Summary of each run and check that every smoke test printed
OKwith 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
| Target | Command |
|---|---|
| Azure App Service | az webapp log tail --name <app> --resource-group cd-lab-rg |
| Elastic Beanstalk | eb logs calc-py-env (from the project folder) |
| App Engine | gcloud 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
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
- F1 has no deployment slots, so rollback redeploys an earlier artifact. The wheel URL keeps the exact bytes of 1.2.0.
- The smoke test confirms the rollback. It expects 1.2.0, so it fails if the old version is not yet live.
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
- Elastic Beanstalk keeps earlier application versions. Pointing the environment at an earlier label is a rollback without any upload.
- The wait command prevents an early check that would still show the new version.
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
- App Engine keeps the previous versions. Moving traffic to a version that is already deployed takes effect at once, and the earlier version does not need a rebuild.
- To keep the failed version for inspection, leave it deployed without traffic. To remove it later, use
gcloud app versions delete.
Step 11.4 Record the rollback
- Open an issue or a note in the repository with the tag that failed, the target, the rollback command, and the time it took.
- Tag a fix as a new patch version. Do not move or reuse the failed tag.
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
- Deleting the Google project removes App Engine and the service account with it. Deletion is delayed by a short recovery period.
- Check each billing page the next day. Also check that no Elastic Beanstalk S3 bucket or CloudFormation stack is left behind.
Step 12.2 Remove the credentials
- Delete the
ci-deployeraccess key in AWS IAM, then delete the user. - Delete the service account key in Google Cloud, then delete
gcp-ci-key.jsonfrom your machine. - 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
- One artifact, several targets. The release is built once and every cloud receives the same bytes.
- One definition of healthy. The same smoke test checks the version and a calculation on every target.
- Route traffic only after verification. App Engine lets you deploy without traffic and promote later. The other targets verify in place.
- Each target has its own rollback. Redeploy a zip, change a version label, or move traffic. Know which one applies before you need it.
- Free means limited. Single instances, scale-to-zero, daily quotas, and budget alerts keep the lab inside its allowance.
Step 12.4 Quiz
- Why does every deploy job use the same smoke test script?
- Why does the App Engine job deploy with
--no-promoteand promote in a later step? - Why does the version ID become
1-2-0and not1.2.0? - 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
- 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.
- 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.
- App Engine version IDs allow only lowercase letters, digits, and hyphens. Dots are not allowed.
- 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
| Symptom | Cause and fix |
|---|---|
Smoke test prints not live yet until it fails | Read 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 denied | Run chmod +x scripts/smoke_test.sh and commit the file mode. |
Google deploy fails with PERMISSION_DENIED | The error names the missing role. Add that role to ci-deployer, then re-run the job. |
| App Engine version ID rejected | Version IDs must be lowercase letters, digits, and hyphens. The tr '.' '-' step handles dots; check any manual name you typed. |
| GCP version host returns 404 | The 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 startup | Check 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 job | Read eb logs. Check that the bundle's requirements name the wheel for the correct tag. |
| Azure job fails with 401 | The publish profile is outdated. Download it again and update the secret. |
| Deploy job waits forever | The environment has a required reviewer. Approve the deployment in the Actions run, or remove the rule for this lab. |
| Budget alert on Google Cloud | Check the App Engine instance hours on the billing page. Set max_instances: 1 and delete unused versions. |
| Release created but a deploy job was skipped | The deploy jobs need the release job to succeed. Read the release job log first. |