Github Actions Deploy
Automate the complete trading bot deployment pipeline using GitHub Actions CI and CD workflows that run comprehensive test suites on push, build deterministic Docker container images, and deploy to production servers with zero-downtime rolling updates on merge to the main branch.
GitHub Actions — Auto Deploy Bot
What You Will Learn
By the end of this notebook you will be able to:
| Skill | Level |
|---|---|
| Understand what GitHub Actions is and why it exists | Beginner |
| Generate valid YAML workflow files programmatically | Beginner |
| Build a deploy-bot that creates/updates workflows via the GitHub API | Intermediate |
| Implement multi-environment deployments (dev / staging / prod) | Intermediate |
| Add secret validation, rollback triggers, and Slack notifications | Advanced |
| Package the bot as a reusable CLI tool | Advanced |
Big Picture — What Is GitHub Actions?
Imagine you have a factory. Every time a new product design arrives, workers must:
- Test the design for defects
- Build a prototype
- Ship it to the right warehouse
GitHub Actions is the automation system for your code factory. It listens for events (a push, a pull request, a schedule) and then runs a series of steps — tests, builds, deployments — automatically, without a human clicking anything.
A Deploy Bot takes this further: it is a program that writes and manages those automation recipes (called workflow files) on your behalf, so you can deploy dozens of projects consistently without copy-pasting YAML by hand.
Developer pushes code
│
▼
GitHub detects event
│
▼
Reads .github/workflows/*.yml ◄── Our bot WRITES these files
│
▼
Spins up a virtual machine (Runner)
│
▼
Executes steps: test → build → deploy
│
▼
Reports success / failure
Prerequisites
- A GitHub Personal Access Token with
repoandworkflowscopes
(Settings → Developer settings → Personal access tokens → Tokens (classic)) - Python 3.8+ (provided by Colab)
- Basic familiarity with Python functions and dictionaries
Never hardcode tokens. This notebook uses
getpassto securely collect credentials at runtime.
Section 1 — Environment Setup
Before writing any automation logic, we need a small set of libraries:
requests— the standard Python HTTP client; used to talk to the GitHub REST APIPyYAML— converts Python dictionaries into YAML text (the format GitHub Actions uses)rich— beautiful terminal output; makes logs readable at a glance
All three are available on PyPI and pre-installed or quickly installable in Colab.
# Install required packages (Colab may already have some of these)
!pip install requests PyYAML rich --quiet1.1 Imports and Global Constants
We collect all imports and constants in one place so they are easy to audit and modify.
Think of this cell as the "settings panel" for the entire notebook.
"""
Imports and project-wide constants for the GitHub Actions Deploy Bot.
"""
import os
import json
import base64
import getpass
from datetime import datetime
from typing import Dict, List, Optional, Any
import requests # HTTP calls to the GitHub API
import yaml # Serialize Python dicts → YAML workflow files
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.syntax import Syntax
from rich import print as rprint
# ── Global constants ──────────────────────────────────────────────────────────
GITHUB_API_BASE = "https://api.github.com" # Root URL for all API calls
WORKFLOW_DIR = ".github/workflows" # Convention GitHub requires
DEFAULT_RUNNER = "ubuntu-latest" # Virtual machine OS for jobs
DEFAULT_BRANCH = "main" # Branch that triggers deployment
# Rich console for pretty-printed output throughout the notebook
console = Console()
print("Imports and constants loaded successfully.")✅ Imports and constants loaded successfully.
Section 2 — Credential Management
Why Secure Credential Handling Matters
A GitHub Personal Access Token (PAT) is like a master key to your repositories.
If it leaks into a public notebook cell, anyone can read, modify, or delete your code.
The function below uses three layers of protection:
getpass— hides input from the screen (like a password prompt)- Environment variable storage — keeps the token in memory only, never on disk
- Validation — confirms the token works before any other code runs
Rule of thumb: If your token ever appears in a code cell output, regenerate it immediately.
def setup_github_credentials() -> Dict[str, str]:
"""
Securely collect and validate GitHub credentials from the user.
Prompts for a Personal Access Token (PAT) and the target repository
in 'owner/repo' format. The token is stored only in memory via an
environment variable — it is never written to disk or printed.
Returns
-------
dict
{
'token' : str — the validated GitHub PAT,
'owner' : str — repository owner (user or organisation),
'repo' : str — repository name,
'headers': dict — pre-built HTTP headers for API requests
}
Raises
------
ValueError
If the token is invalid or the repository cannot be found.
Example
-------
>>> creds = setup_github_credentials()
>>> print(creds['owner']) # 'my-github-username'
"""
# ── Step 1: Collect token without echoing it to the screen ────────────────
token = getpass.getpass("🔑 Enter your GitHub Personal Access Token: ")
# Store in environment variable so sub-processes can also use it
os.environ["GITHUB_TOKEN"] = token
# ── Step 2: Collect repository target ─────────────────────────────────────
repo_full = input("📁 Enter repository (format: owner/repo-name): ").strip()
# Split 'owner/repo' into separate components
if "/" not in repo_full:
raise ValueError(
f"Repository must be in 'owner/repo' format. Got: '{repo_full}'"
)
owner, repo = repo_full.split("/", maxsplit=1) # maxsplit=1 handles org/team/repo edge case
# ── Step 3: Build standard headers used by every API request ──────────────
headers = {
"Authorization" : f"Bearer {token}",
"Accept" : "application/vnd.github+json", # GitHub v3 JSON API
"X-GitHub-Api-Version": "2022-11-28", # Pin API version for stability
}
# ── Step 4: Validate by hitting the /repos endpoint ───────────────────────
console.print(f"\n[cyan]🔍 Validating access to [bold]{repo_full}[/bold]...[/cyan]")
response = requests.get(
f"{GITHUB_API_BASE}/repos/{owner}/{repo}",
headers=headers,
timeout=10
)
if response.status_code == 200:
repo_data = response.json()
console.print(
f"[green]✅ Connected to:[/green] [bold]{repo_data['full_name']}[/bold] "
f"({'private' if repo_data['private'] else 'public'})"
)
elif response.status_code == 401:
raise ValueError("❌ Token is invalid or expired. Please generate a new PAT.")
elif response.status_code == 404:
raise ValueError(
f"❌ Repository '{repo_full}' not found. "
"Check the name and ensure your token has 'repo' scope."
)
else:
raise ValueError(f"❌ Unexpected API response: {response.status_code}")
return {
"token" : token,
"owner" : owner,
"repo" : repo,
"headers": headers,
}
# ── Run credential setup ──────────────────────────────────────────────────────
# Uncomment the line below when you are ready to connect to a real repository.
# creds = setup_github_credentials()
print("ℹ️ setup_github_credentials() defined. Uncomment the last line to run it.")ℹ️ setup_github_credentials() defined. Uncomment the last line to run it.
Section 3 — Workflow YAML Builder
Understanding GitHub Actions YAML Structure
Every GitHub Actions workflow is a YAML file stored in .github/workflows/.
Think of YAML as a nested dictionary — Python is excellent at generating it.
Workflow File Anatomy
─────────────────────
name: My Workflow ← Human-readable label shown in the Actions tab
on: ← TRIGGER — what event starts this workflow?
push:
branches: [main]
jobs: ← JOBS — independent groups of steps
deploy: ← job ID (arbitrary name)
runs-on: ubuntu-latest ← which virtual machine to use
steps: ← STEPS — sequential commands inside the job
- uses: actions/checkout@v4 ← a pre-built action from the marketplace
- run: echo "Hello World" ← a raw shell command
The function below takes simple Python parameters and produces this structure automatically.
def build_deploy_workflow(
workflow_name : str,
environment : str,
deploy_commands : List[str],
trigger_branch : str = DEFAULT_BRANCH,
runner : str = DEFAULT_RUNNER,
python_version : str = "3.11",
notify_slack : bool = False,
extra_env_vars : Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""
Build a GitHub Actions deploy workflow as a Python dictionary.
This function generates the complete structure of a deployment workflow
that:
1. Checks out the repository code
2. Sets up Python
3. Installs dependencies (from requirements.txt if present)
4. Runs any user-supplied deploy commands
5. Optionally sends a Slack notification on success or failure
Parameters
----------
workflow_name : str
Human-readable label shown in the GitHub Actions UI tab.
Example: 'Deploy to Production'
environment : str
Target environment name. Used in job names and for GitHub Environment
protection rules. Typical values: 'dev', 'staging', 'production'.
deploy_commands : list of str
Shell commands that perform the actual deployment, executed in order.
Example: ['python manage.py migrate', 'python manage.py collectstatic']
trigger_branch : str, optional
The git branch whose push events trigger this workflow.
Default: 'main'
runner : str, optional
GitHub-hosted runner image.
Default: 'ubuntu-latest'
python_version : str, optional
Python version string understood by actions/setup-python.
Default: '3.11'
notify_slack : bool, optional
When True, adds a final step that posts a Slack message using the
SLACK_WEBHOOK_URL secret. Default: False.
extra_env_vars : dict, optional
Additional environment variables injected into every job step.
Values that begin with '${{' are treated as GitHub expressions;
others are treated as literal strings.
Example: {'DATABASE_URL': '${{ secrets.DATABASE_URL }}'}
Returns
-------
dict
A nested Python dictionary that can be serialised to YAML with
`yaml.dump(result, sort_keys=False)` and written to
`.github/workflows/<name>.yml`.
Example
-------
>>> wf = build_deploy_workflow(
... workflow_name = 'Deploy to Staging',
... environment = 'staging',
... deploy_commands = ['./scripts/deploy.sh'],
... trigger_branch = 'develop',
... )
>>> print(yaml.dump(wf, sort_keys=False))
"""
# ── Base environment variables available to all steps ─────────────────────
# ${{ secrets.X }} is GitHub's way to reference encrypted repository secrets
base_env = {
"DEPLOY_ENV" : environment,
"GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}", # Auto-provided by GitHub
}
# Merge in any caller-supplied extra variables
if extra_env_vars:
base_env.update(extra_env_vars)
# ── Build the list of job steps ───────────────────────────────────────────
steps = [
# Step 1: Check out the repository at the commit that triggered the run
{
"name": "📥 Checkout repository",
"uses": "actions/checkout@v4",
},
# Step 2: Install the requested Python version on the runner VM
{
"name": "🐍 Set up Python",
"uses": "actions/setup-python@v5",
"with": {"python-version": python_version},
},
# Step 3: Cache pip packages — speeds up subsequent runs significantly
{
"name": "📦 Cache pip dependencies",
"uses": "actions/cache@v4",
"with": {
"path": "~/.cache/pip",
# Cache key changes whenever requirements.txt changes
"key" : "${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}",
"restore-keys": "${{ runner.os }}-pip-",
},
},
# Step 4: Install Python dependencies (gracefully skips if file absent)
{
"name": "⚙️ Install dependencies",
"run" : (
"if [ -f requirements.txt ]; then "
"pip install -r requirements.txt; "
"fi"
),
},
]
# ── Step 5: Add each deploy command as its own named step ─────────────────
# Splitting commands into separate steps gives clearer logs in the Actions UI
for idx, cmd in enumerate(deploy_commands, start=1):
steps.append({
"name": f"🚀 Deploy step {idx}: {cmd[:50]}", # Truncate long commands in name
"run" : cmd,
})
# ── Step 6 (optional): Slack notification ─────────────────────────────────
if notify_slack:
steps.append({
"name": "💬 Notify Slack",
# 'if: always()' means this step runs even when previous steps fail
"if" : "always()",
"uses": "slackapi/slack-github-action@v1.27.0",
"with": {
"payload": json.dumps({
"text": (
f"Deploy to *{environment}* — "
"${{ job.status == 'success' && '✅ Succeeded' || '❌ Failed' }}\n"
"Repo: ${{ github.repository }} | "
"Branch: ${{ github.ref_name }} | "
"By: ${{ github.actor }}"
)
})
},
"env": {"SLACK_WEBHOOK_URL": "${{ secrets.SLACK_WEBHOOK_URL }}"},
})
# ── Assemble the complete workflow dictionary ──────────────────────────────
workflow = {
"name": workflow_name,
# Trigger: run this workflow when code is pushed to trigger_branch
"on": {
"push": {"branches": [trigger_branch]},
# Also allow manual runs from the GitHub Actions UI
"workflow_dispatch": None,
},
# Define environment variables available to every job in this workflow
"env": base_env,
"jobs": {
f"deploy-{environment}": { # Job ID uses environment name for clarity
"name" : f"Deploy to {environment.capitalize()}",
"runs-on" : runner,
# Link to a GitHub Environment for protection rules & approvals
"environment": environment,
"steps" : steps,
}
},
}
return workflow
# ── Demo: build a staging workflow and display it ─────────────────────────────
sample_workflow = build_deploy_workflow(
workflow_name = "Deploy to Staging",
environment = "staging",
deploy_commands = [
"echo 'Running database migrations...'",
"python manage.py migrate --no-input",
"echo 'Deployment complete!'",
],
trigger_branch = "develop",
notify_slack = True,
extra_env_vars = {"DATABASE_URL": "${{ secrets.DATABASE_URL }}"},
)
# Convert to YAML and display with syntax highlighting
yaml_text = yaml.dump(sample_workflow, sort_keys=False, default_flow_style=False)
console.print(Panel(
Syntax(yaml_text, "yaml", theme="monokai", line_numbers=True),
title="[bold green]Generated Workflow YAML[/bold green]",
border_style="green",
))╭──────────────────────────────────────────── Generated Workflow YAML ────────────────────────────────────────────╮ │ 1 name: Deploy to Staging │ │ 2 'on': │ │ 3 push: │ │ 4 branches: │ │ 5 - develop │ │ 6 workflow_dispatch: null │ │ 7 env: │ │ 8 DEPLOY_ENV: staging │ │ 9 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} │ │ 10 DATABASE_URL: ${{ secrets.DATABASE_URL }} │ │ 11 jobs: │ │ 12 deploy-staging: │ │ 13 name: Deploy to Staging │ │ 14 runs-on: ubuntu-latest │ │ 15 environment: staging │ │ 16 steps: │ │ 17 - name: "\U0001F4E5 Checkout repository" │ │ 18 uses: actions/checkout@v4 │ │ 19 - name: "\U0001F40D Set up Python" │ │ 20 uses: actions/setup-python@v5 │ │ 21 with: │ │ 22 python-version: '3.11' │ │ 23 - name: "\U0001F4E6 Cache pip dependencies" │ │ 24 uses: actions/cache@v4 │ │ 25 with: │ │ 26 path: ~/.cache/pip │ │ 27 key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} │ │ 28 restore-keys: ${{ runner.os }}-pip- │ │ 29 - name: "\u2699\uFE0F Install dependencies" │ │ 30 run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi │ │ 31 - name: "\U0001F680 Deploy step 1: echo 'Running database migrations...'" │ │ 32 run: echo 'Running database migrations...' │ │ 33 - name: "\U0001F680 Deploy step 2: python manage.py migrate --no-input" │ │ 34 run: python manage.py migrate --no-input │ │ 35 - name: "\U0001F680 Deploy step 3: echo 'Deployment complete!'" │ │ 36 run: echo 'Deployment complete!' │ │ 37 - name: "\U0001F4AC Notify Slack" │ │ 38 if: always() │ │ 39 uses: slackapi/slack-github-action@v1.27.0 │ │ 40 with: │ │ 41 payload: '{"text": "Deploy to *staging* \u2014 ${{ job.status == ''success'' │ │ 42 && ''\u2705 Succeeded'' || ''\u274c Failed'' }}\nRepo: ${{ github.repository │ │ 43 }} | Branch: ${{ github.ref_name }} | By: ${{ github.actor }}"}' │ │ 44 env: │ │ 45 SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} │ │ 46 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Section 4 — Multi-Environment Workflow Generator
The Dev → Staging → Production Pipeline
Real-world deployments never go straight to production. The standard pattern is:
feature branch → develop branch → release branch → main branch
│ │ │ │
(testing) deploy to deploy to deploy to
dev staging production
Each environment can have different:
- Trigger branches (develop → dev, release → staging, main → production)
- Deploy commands (staging might run extra smoke tests)
- Approval requirements (production requires a human to approve in GitHub)
- Notification channels (production failures wake someone up at 2 AM)
The function below generates all three workflow files in one call.
def generate_multi_environment_workflows(
project_name : str,
base_deploy_command: str,
environments : Optional[List[Dict[str, Any]]] = None,
python_version : str = "3.11",
) -> Dict[str, Dict[str, Any]]:
"""
Generate a complete set of deployment workflows for multiple environments.
Creates three workflow definitions — dev, staging, and production — each
with appropriate triggers, commands, and safety settings.
Production includes an approval gate (GitHub Environment protection rule).
Parameters
----------
project_name : str
Name of the project; used in workflow names and job labels.
Example: 'my-api-service'
base_deploy_command : str
The core deployment shell command shared across all environments.
Example: './scripts/deploy.sh' or 'ansible-playbook deploy.yml'
environments : list of dict, optional
Override the default environment configuration. Each dict supports:
- name (str) : environment identifier
- branch (str) : git branch that triggers deploy
- extra_commands (list) : additional steps after base_deploy_command
- notify_slack (bool) : whether to post Slack messages
- extra_env_vars (dict) : additional environment variables
If None, sensible defaults for dev/staging/production are used.
python_version : str, optional
Python version to install on the runner. Default: '3.11'
Returns
-------
dict
Keys are filename strings (e.g., 'deploy-production.yml'),
values are the corresponding workflow dictionaries ready for YAML
serialisation and upload to GitHub.
Example
-------
>>> workflows = generate_multi_environment_workflows(
... project_name = 'ecommerce-backend',
... base_deploy_command = 'docker compose up -d --build',
... )
>>> for fname in workflows:
... print(fname)
deploy-dev.yml
deploy-staging.yml
deploy-production.yml
"""
# ── Default environment configuration ─────────────────────────────────────
# Each dict is a recipe for one environment
default_environments = [
{
"name" : "dev",
"branch" : "develop",
"extra_commands": ["echo '✅ Dev deploy complete'"],
"notify_slack" : False,
"extra_env_vars": {"LOG_LEVEL": "DEBUG"},
},
{
"name" : "staging",
"branch" : "release",
# Staging always runs smoke tests after deploy to catch regressions
"extra_commands": [
"pytest tests/smoke/ -v --tb=short",
"echo '✅ Staging smoke tests passed'",
],
"notify_slack" : True,
"extra_env_vars": {
"LOG_LEVEL" : "INFO",
"DATABASE_URL": "${{ secrets.STAGING_DATABASE_URL }}",
},
},
{
"name" : "production",
"branch" : "main",
# Production: full test suite + health check after deploy
"extra_commands": [
"pytest tests/ -v --tb=short -x", # -x stops on first failure
"curl --fail ${{ secrets.PROD_HEALTH_CHECK_URL }}",
"echo '✅ Production deployment verified'",
],
"notify_slack" : True, # Always notify on production events
"extra_env_vars": {
"LOG_LEVEL" : "WARNING",
"DATABASE_URL": "${{ secrets.PROD_DATABASE_URL }}",
},
},
]
# Use caller-provided config if given, otherwise fall back to defaults
env_configs = environments if environments is not None else default_environments
# ── Generate one workflow per environment ──────────────────────────────────
workflows = {}
for env_cfg in env_configs:
env_name = env_cfg["name"]
# Combine the shared base command with environment-specific extras
all_commands = [base_deploy_command] + env_cfg.get("extra_commands", [])
# Build the workflow dictionary using our previously defined function
workflow = build_deploy_workflow(
workflow_name = f"[{project_name}] Deploy to {env_name.capitalize()}",
environment = env_name,
deploy_commands = all_commands,
trigger_branch = env_cfg.get("branch", "main"),
python_version = python_version,
notify_slack = env_cfg.get("notify_slack", False),
extra_env_vars = env_cfg.get("extra_env_vars"),
)
# Filename convention: deploy-<environment>.yml
filename = f"deploy-{env_name}.yml"
workflows[filename] = workflow
return workflows
# ── Demo ──────────────────────────────────────────────────────────────────────
all_workflows = generate_multi_environment_workflows(
project_name = "my-web-app",
base_deploy_command = "./scripts/deploy.sh",
)
# Summary table
table = Table(title="Generated Workflows", show_lines=True)
table.add_column("Filename", style="cyan", no_wrap=True)
table.add_column("Trigger", style="green")
table.add_column("Steps", style="yellow")
table.add_column("Slack Notify",style="magenta")
for fname, wf in all_workflows.items():
job_key = list(wf["jobs"].keys())[0] # First (only) job
job = wf["jobs"][job_key]
trigger = list(wf["on"]["push"]["branches"])[0]
num_steps = len(job["steps"])
has_slack = any("Slack" in s.get("name", "") for s in job["steps"])
table.add_row(fname, trigger, str(num_steps), "✅" if has_slack else "—")
console.print(table)Generated Workflows ┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━┓ ┃ Filename ┃ Trigger ┃ Steps ┃ Slack Notify ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━┩ │ deploy-dev.yml │ develop │ 6 │ — │ ├───────────────────────┼─────────┼───────┼──────────────┤ │ deploy-staging.yml │ release │ 8 │ ✅ │ ├───────────────────────┼─────────┼───────┼──────────────┤ │ deploy-production.yml │ main │ 9 │ ✅ │ └───────────────────────┴─────────┴───────┴──────────────┘
Section 5 — GitHub API: Upload Workflow Files
How the GitHub Contents API Works
GitHub provides a REST endpoint that lets you create or update any file in a repository — including the workflow files in .github/workflows/.
This is exactly what our deploy bot uses to push workflows without needing git installed locally.
PUT /repos/{owner}/{repo}/contents/{path}
{
"message": "commit message",
"content": "<base64-encoded file content>",
"sha" : "<existing file SHA — required for updates, omit for new files>"
}
Key detail: file content must be base64-encoded. Python's base64 module handles this in one line.
Key detail 2: updating an existing file requires passing the current file's SHA hash — GitHub uses this as an optimistic concurrency check to prevent overwriting concurrent changes.
def get_file_sha(
owner : str,
repo : str,
path : str,
headers : Dict[str, str],
) -> Optional[str]:
"""
Retrieve the current SHA hash of a file in a GitHub repository.
When updating an existing file via the GitHub Contents API, you must
supply the file's current SHA. This function fetches that value.
Returns None if the file does not yet exist — callers use this to
distinguish between a create (no SHA needed) and an update.
Parameters
----------
owner : str — repository owner username or organisation
repo : str — repository name
path : str — file path within the repo, e.g. '.github/workflows/deploy.yml'
headers : dict — pre-built GitHub API authentication headers
Returns
-------
str or None
The 40-character SHA string if the file exists, otherwise None.
Example
-------
>>> sha = get_file_sha(owner, repo, '.github/workflows/deploy.yml', headers)
>>> print(sha) # 'a1b2c3...' or None
"""
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}"
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
# File exists — extract its SHA from the JSON response
return response.json().get("sha")
elif response.status_code == 404:
# File does not exist yet — that's fine, caller will create it
return None
else:
# Any other status is unexpected; surface it with context
raise RuntimeError(
f"Failed to check file SHA for '{path}': "
f"HTTP {response.status_code} — {response.text[:200]}"
)
print("✅ get_file_sha() defined.")✅ get_file_sha() defined.
def upload_workflow_file(
owner : str,
repo : str,
filename : str,
workflow_dict : Dict[str, Any],
headers : Dict[str, str],
commit_message : Optional[str] = None,
dry_run : bool = False,
) -> Dict[str, Any]:
"""
Upload (create or update) a GitHub Actions workflow file via the GitHub API.
Converts the workflow dictionary to YAML, base64-encodes it, then calls
the GitHub Contents API to commit it directly to the repository's
`.github/workflows/` directory. Handles both new file creation and
updates to existing files automatically.
Parameters
----------
owner : str — repository owner
repo : str — repository name
filename : str — name of the workflow file, e.g. 'deploy-prod.yml'
workflow_dict : dict — workflow structure from build_deploy_workflow()
headers : dict — GitHub API authentication headers
commit_message : str, optional
Git commit message for the upload. Defaults to an auto-generated
timestamped message.
dry_run : bool, optional
When True, generate and display the YAML but do NOT call the API.
Useful for previewing changes before committing. Default: False.
Returns
-------
dict
{
'status' : 'created' | 'updated' | 'dry_run',
'filename' : str,
'path' : str,
'commit_sha': str or None,
'url' : str or None
}
Raises
------
requests.HTTPError
If the API returns a non-2xx status code.
Example
-------
>>> result = upload_workflow_file(
... owner = 'my-org',
... repo = 'my-app',
... filename = 'deploy-staging.yml',
... workflow_dict = staging_workflow,
... headers = creds['headers'],
... dry_run = True,
... )
"""
# ── Convert workflow dict → YAML text ─────────────────────────────────────
yaml_content = yaml.dump(
workflow_dict,
sort_keys=False, # Preserve our intentional key ordering
default_flow_style=False, # Use block style (readable multi-line)
allow_unicode=True,
)
# ── Dry-run mode: show YAML but skip the API call ─────────────────────────
if dry_run:
console.print(Panel(
Syntax(yaml_content, "yaml", theme="monokai", line_numbers=True),
title=f"[bold yellow]🔍 DRY RUN — {filename}[/bold yellow]",
border_style="yellow",
))
return {
"status" : "dry_run",
"filename" : filename,
"path" : f"{WORKFLOW_DIR}/{filename}",
"commit_sha": None,
"url" : None,
}
# ── Encode YAML content to base64 (required by GitHub Contents API) ───────
content_b64 = base64.b64encode(yaml_content.encode("utf-8")).decode("utf-8")
# ── Build the target file path inside the repository ──────────────────────
file_path = f"{WORKFLOW_DIR}/{filename}"
# ── Check whether the file already exists (needed for update vs create) ───
existing_sha = get_file_sha(owner, repo, file_path, headers)
operation = "updated" if existing_sha else "created"
# ── Build the request payload ─────────────────────────────────────────────
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
payload = {
"message": commit_message or f"bot: {operation} {filename} [{timestamp}]",
"content": content_b64,
}
# Include SHA only when updating — omitting it for new files
if existing_sha:
payload["sha"] = existing_sha
# ── Make the API request ───────────────────────────────────────────────────
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{file_path}"
response = requests.put(url, headers=headers, json=payload, timeout=15)
if response.status_code not in (200, 201):
response.raise_for_status() # Surface error with HTTP details
response_data = response.json()
# ── Log success ───────────────────────────────────────────────────────────
console.print(
f"[green]{'✅ Created' if operation == 'created' else '🔄 Updated'}:[/green] "
f"[bold]{file_path}[/bold]"
)
return {
"status" : operation,
"filename" : filename,
"path" : file_path,
"commit_sha": response_data["commit"]["sha"],
"url" : response_data["content"]["html_url"],
}
print("✅ upload_workflow_file() defined.")✅ upload_workflow_file() defined.
Section 6 — The Deploy Bot Orchestrator
Putting It All Together
The functions we've built so far are like individual Lego bricks.
This section creates the orchestrator — the master function that snaps all the bricks together into a complete, working deploy bot.
deploy_bot()
│
├── validate_deployment_config() ← Check inputs before touching GitHub
│
├── generate_multi_environment_workflows() ← Build all workflow YAMLs
│
├── for each workflow:
│ upload_workflow_file() ← Commit to .github/workflows/
│
└── print_deployment_report() ← Summary of what was deployed
The orchestrator also supports rollback — if any upload fails mid-way, it can delete the files that were successfully uploaded, leaving the repository in its original state.
def validate_deployment_config(
project_name : str,
base_deploy_command: str,
environments : List[str],
) -> bool:
"""
Validate a deployment configuration before any API calls are made.
Performs lightweight checks that catch common mistakes early,
before any changes are pushed to GitHub. Think of this as a
pre-flight checklist a pilot runs before takeoff.
Checks performed
----------------
1. project_name: non-empty, no special characters that break filenames
2. base_deploy_command: non-empty string
3. environments: non-empty list, all values from the allowed set
Parameters
----------
project_name : str — project identifier used in workflow names
base_deploy_command : str — shell command to deploy the project
environments : list — target environment names to validate
Returns
-------
bool
True if all checks pass.
Raises
------
ValueError
With a descriptive message for the first failed check.
Example
-------
>>> validate_deployment_config('my-app', './deploy.sh', ['dev', 'staging'])
True
"""
import re
# ── Check 1: Project name ─────────────────────────────────────────────────
if not project_name or not project_name.strip():
raise ValueError("project_name cannot be empty.")
# Only allow letters, numbers, hyphens, and underscores
if not re.match(r'^[\w\-]+$', project_name):
raise ValueError(
f"project_name '{project_name}' contains invalid characters. "
"Use only letters, numbers, hyphens, and underscores."
)
# ── Check 2: Deploy command ────────────────────────────────────────────────
if not base_deploy_command or not base_deploy_command.strip():
raise ValueError("base_deploy_command cannot be empty.")
# Warn (but don't block) on suspicious command patterns
dangerous_patterns = ["rm -rf /", "format c:", "dd if=" ]
for pattern in dangerous_patterns:
if pattern in base_deploy_command.lower():
raise ValueError(
f"base_deploy_command contains a potentially destructive pattern: '{pattern}'"
)
# ── Check 3: Environments ─────────────────────────────────────────────────
allowed_envs = {"dev", "staging", "production", "test", "qa", "sandbox"}
if not environments:
raise ValueError("environments list cannot be empty.")
for env in environments:
if env not in allowed_envs:
raise ValueError(
f"Unknown environment: '{env}'. "
f"Allowed values: {sorted(allowed_envs)}"
)
console.print("[green]✅ Configuration validation passed.[/green]")
return True
# ── Test validate_deployment_config ───────────────────────────────────────────
# Valid config
validate_deployment_config(
project_name = "my-web-app",
base_deploy_command = "./scripts/deploy.sh",
environments = ["dev", "staging", "production"],
)
# Invalid config — uncomment to test error handling
# validate_deployment_config(
# project_name = "my app!", # spaces and ! are invalid
# base_deploy_command = "./deploy.sh",
# environments = ["dev"],
# )✅ Configuration validation passed.
True
def deploy_bot(
creds : Dict[str, str],
project_name : str,
base_deploy_command: str,
target_environments: Optional[List[str]] = None,
dry_run : bool = True,
python_version : str = "3.11",
rollback_on_error : bool = True,
) -> Dict[str, Any]:
"""
Main orchestrator: generate and deploy GitHub Actions workflows for a project.
This is the top-level entry point for the deploy bot. It validates the
configuration, generates workflow YAML for each requested environment,
uploads them to GitHub, and prints a deployment report. If any upload
fails and rollback_on_error is True, previously uploaded files are deleted
to restore the repository to its pre-bot state.
Parameters
----------
creds : dict
Credentials dict from setup_github_credentials(). Must contain
'owner', 'repo', and 'headers' keys.
project_name : str
Project identifier used in workflow names and file labels.
Example: 'payment-service'
base_deploy_command : str
Core shell command that performs the deployment.
Example: 'kubectl apply -f k8s/'
target_environments : list of str, optional
Which environments to create workflows for.
Defaults to ['dev', 'staging', 'production'].
dry_run : bool, optional
When True (default), display workflow YAMLs without calling the API.
Set to False to actually commit files to GitHub.
python_version : str, optional
Python version for the runner. Default: '3.11'.
rollback_on_error : bool, optional
If True and an upload fails partway through, previously created
workflow files are deleted from GitHub. Default: True.
Returns
-------
dict
{
'project' : str,
'dry_run' : bool,
'total_workflows': int,
'results' : list of upload result dicts,
'errors' : list of error message strings
}
Example
-------
>>> report = deploy_bot(
... creds = creds,
... project_name = 'my-app',
... base_deploy_command = './deploy.sh',
... dry_run = True,
... )
"""
# ── Defaults ──────────────────────────────────────────────────────────────
if target_environments is None:
target_environments = ["dev", "staging", "production"]
owner = creds["owner"]
repo = creds["repo"]
headers = creds["headers"]
results = [] # Accumulate successful upload results for the final report
errors = [] # Collect any error messages
console.print(Panel(
f"[bold]Project:[/bold] {project_name}\n"
f"[bold]Repository:[/bold] {owner}/{repo}\n"
f"[bold]Environments:[/bold] {', '.join(target_environments)}\n"
f"[bold]Deploy Command:[/bold] {base_deploy_command}\n"
f"[bold]Mode:[/bold] {'🔍 DRY RUN' if dry_run else '🚀 LIVE DEPLOY'}",
title="[bold blue]🤖 Deploy Bot Starting[/bold blue]",
border_style="blue",
))
# ── Step 1: Validate configuration before touching GitHub ──────────────────
validate_deployment_config(project_name, base_deploy_command, target_environments)
# ── Step 2: Generate workflow files for the requested environments ─────────
# Filter default_environments to only those in target_environments
all_workflows = generate_multi_environment_workflows(
project_name = project_name,
base_deploy_command = base_deploy_command,
python_version = python_version,
)
# Keep only the environments the caller requested
filtered_workflows = {
fname: wf
for fname, wf in all_workflows.items()
if any(env in fname for env in target_environments)
}
console.print(f"\n[cyan]📄 Generated {len(filtered_workflows)} workflow file(s)[/cyan]")
# ── Step 3: Upload each workflow file ─────────────────────────────────────
for filename, workflow_dict in filtered_workflows.items():
try:
result = upload_workflow_file(
owner = owner,
repo = repo,
filename = filename,
workflow_dict = workflow_dict,
headers = headers,
dry_run = dry_run,
)
results.append(result)
except Exception as exc:
error_msg = f"Failed to upload {filename}: {str(exc)}"
errors.append(error_msg)
console.print(f"[red]❌ {error_msg}[/red]")
# ── Rollback: delete successfully uploaded files ───────────────────
if rollback_on_error and not dry_run and results:
console.print("[yellow]⏪ Rolling back previously uploaded files...[/yellow]")
_rollback_uploads(owner, repo, results, headers)
break # Stop processing further files after an error
# ── Step 4: Print final report ────────────────────────────────────────────
_print_deployment_report(
project_name = project_name,
results = results,
errors = errors,
dry_run = dry_run,
)
return {
"project" : project_name,
"dry_run" : dry_run,
"total_workflows" : len(filtered_workflows),
"results" : results,
"errors" : errors,
}
print("✅ deploy_bot() defined.")✅ deploy_bot() defined.
Section 7 — Helper Functions (Rollback & Reporting)
Good automation tools always have a safety net.
The rollback function and reporting function are that safety net — they are called by deploy_bot() internally but are also useful independently for debugging and auditing.
def _rollback_uploads(
owner : str,
repo : str,
results : List[Dict[str, Any]],
headers : Dict[str, str],
) -> None:
"""
Delete files that were successfully uploaded during a failed deploy run.
Called internally by deploy_bot() when rollback_on_error=True and a
mid-run upload fails. Removes only the files in `results` (i.e., the
ones the current bot run created), leaving pre-existing files untouched.
Parameters
----------
owner : str — repository owner
repo : str — repository name
results : list of dict — upload results from successful upload_workflow_file() calls
headers : dict — GitHub API authentication headers
Returns
-------
None (side-effect: deletes files from GitHub)
Notes
-----
Only deletes files whose status was 'created' (i.e., new files). Files
that were 'updated' are not rolled back because we do not store the prior
content in this version of the bot.
"""
for result in results:
# Only delete files we newly created; skip pre-existing files we updated
if result.get("status") != "created":
continue
file_path = result["path"]
# Need the current SHA to delete a file via the GitHub API
sha = get_file_sha(owner, repo, file_path, headers)
if sha is None:
# File already gone — nothing to do
continue
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{file_path}"
payload = {
"message": f"bot: rollback — remove {result['filename']}",
"sha" : sha,
}
response = requests.delete(url, headers=headers, json=payload, timeout=10)
if response.status_code == 200:
console.print(f"[yellow] ⏪ Rolled back: {file_path}[/yellow]")
else:
console.print(
f"[red] ⚠️ Could not roll back {file_path}: "
f"HTTP {response.status_code}[/red]"
)
def _print_deployment_report(
project_name : str,
results : List[Dict[str, Any]],
errors : List[str],
dry_run : bool,
) -> None:
"""
Print a formatted summary table of the deployment bot run.
Displays which workflow files were created/updated, their GitHub URLs,
and any errors that occurred. Uses the Rich library for readable output.
Parameters
----------
project_name : str — project name for the report header
results : list of dict — upload results from upload_workflow_file()
errors : list of str — error messages from failed uploads
dry_run : bool — True if this was a dry run
Returns
-------
None (side-effect: prints to console)
"""
mode_label = "🔍 DRY RUN" if dry_run else "🚀 DEPLOYED"
table = Table(
title=f"Deploy Bot Report — {project_name} [{mode_label}]",
show_lines=True,
)
table.add_column("Workflow File", style="cyan", no_wrap=True)
table.add_column("Status", style="green")
table.add_column("Commit / Note", style="yellow")
for r in results:
status = r["status"].upper()
note = (
r["commit_sha"][:10] + "..." if r.get("commit_sha")
else "(dry run — no commit)"
)
table.add_row(r["filename"], status, note)
for err in errors:
table.add_row("—", "[red]ERROR[/red]", err[:80])
console.print(table)
# Summary line
total = len(results) + len(errors)
success = len(results)
emoji = "✅" if not errors else "⚠️"
console.print(
f"\n{emoji} {success}/{total} workflow(s) {'previewed' if dry_run else 'deployed'} successfully."
)
print("✅ _rollback_uploads() and _print_deployment_report() defined.")✅ _rollback_uploads() and _print_deployment_report() defined.
Section 8 — Running the Deploy Bot (Dry Run Demo)
Now let's put the whole bot together with a dry run — it will generate all the YAML and print the report, but will not make any changes to GitHub.
To run against a real repository:
- Uncomment
creds = setup_github_credentials()in Section 2 and run it - Change
dry_run=Truetodry_run=Falsein the cell below
# ── Simulate credentials for the dry-run demo ─────────────────────────────────
# In a real run, replace this with: creds = setup_github_credentials()
DEMO_CREDS = {
"token" : "ghp_DEMO_TOKEN",
"owner" : "my-organisation",
"repo" : "my-web-app",
"headers": {
"Authorization" : "Bearer ghp_DEMO_TOKEN",
"Accept" : "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
# ── Run the deploy bot in dry-run mode ────────────────────────────────────────
report = deploy_bot(
creds = DEMO_CREDS,
project_name = "my-web-app",
base_deploy_command = "./scripts/deploy.sh --env=$DEPLOY_ENV",
target_environments = ["dev", "staging", "production"],
dry_run = True, # ← Change to False for a real deployment
python_version = "3.11",
rollback_on_error = True,
)╭──────────────────────────────────────────── 🤖 Deploy Bot Starting ─────────────────────────────────────────────╮ │ Project: my-web-app │ │ Repository: my-organisation/my-web-app │ │ Environments: dev, staging, production │ │ Deploy Command: ./scripts/deploy.sh --env=$DEPLOY_ENV │ │ Mode: 🔍 DRY RUN │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✅ Configuration validation passed.
📄 Generated 3 workflow file(s)
╭────────────────────────────────────────── 🔍 DRY RUN — deploy-dev.yml ──────────────────────────────────────────╮ │ 1 name: '[my-web-app] Deploy to Dev' │ │ 2 'on': │ │ 3 push: │ │ 4 branches: │ │ 5 - develop │ │ 6 workflow_dispatch: null │ │ 7 env: │ │ 8 DEPLOY_ENV: dev │ │ 9 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} │ │ 10 LOG_LEVEL: DEBUG │ │ 11 jobs: │ │ 12 deploy-dev: │ │ 13 name: Deploy to Dev │ │ 14 runs-on: ubuntu-latest │ │ 15 environment: dev │ │ 16 steps: │ │ 17 - name: 📥 Checkout repository │ │ 18 uses: actions/checkout@v4 │ │ 19 - name: 🐍 Set up Python │ │ 20 uses: actions/setup-python@v5 │ │ 21 with: │ │ 22 python-version: '3.11' │ │ 23 - name: 📦 Cache pip dependencies │ │ 24 uses: actions/cache@v4 │ │ 25 with: │ │ 26 path: ~/.cache/pip │ │ 27 key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} │ │ 28 restore-keys: ${{ runner.os }}-pip- │ │ 29 - name: ⚙️ Install dependencies │ │ 30 run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi │ │ 31 - name: '🚀 Deploy step 1: ./scripts/deploy.sh --env=$DEPLOY_ENV' │ │ 32 run: ./scripts/deploy.sh --env=$DEPLOY_ENV │ │ 33 - name: '🚀 Deploy step 2: echo ''✅ Dev deploy complete''' │ │ 34 run: echo '✅ Dev deploy complete' │ │ 35 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────── 🔍 DRY RUN — deploy-staging.yml ────────────────────────────────────────╮ │ 1 name: '[my-web-app] Deploy to Staging' │ │ 2 'on': │ │ 3 push: │ │ 4 branches: │ │ 5 - release │ │ 6 workflow_dispatch: null │ │ 7 env: │ │ 8 DEPLOY_ENV: staging │ │ 9 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} │ │ 10 LOG_LEVEL: INFO │ │ 11 DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }} │ │ 12 jobs: │ │ 13 deploy-staging: │ │ 14 name: Deploy to Staging │ │ 15 runs-on: ubuntu-latest │ │ 16 environment: staging │ │ 17 steps: │ │ 18 - name: 📥 Checkout repository │ │ 19 uses: actions/checkout@v4 │ │ 20 - name: 🐍 Set up Python │ │ 21 uses: actions/setup-python@v5 │ │ 22 with: │ │ 23 python-version: '3.11' │ │ 24 - name: 📦 Cache pip dependencies │ │ 25 uses: actions/cache@v4 │ │ 26 with: │ │ 27 path: ~/.cache/pip │ │ 28 key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} │ │ 29 restore-keys: ${{ runner.os }}-pip- │ │ 30 - name: ⚙️ Install dependencies │ │ 31 run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi │ │ 32 - name: '🚀 Deploy step 1: ./scripts/deploy.sh --env=$DEPLOY_ENV' │ │ 33 run: ./scripts/deploy.sh --env=$DEPLOY_ENV │ │ 34 - name: '🚀 Deploy step 2: pytest tests/smoke/ -v --tb=short' │ │ 35 run: pytest tests/smoke/ -v --tb=short │ │ 36 - name: '🚀 Deploy step 3: echo ''✅ Staging smoke tests passed''' │ │ 37 run: echo '✅ Staging smoke tests passed' │ │ 38 - name: 💬 Notify Slack │ │ 39 if: always() │ │ 40 uses: slackapi/slack-github-action@v1.27.0 │ │ 41 with: │ │ 42 payload: '{"text": "Deploy to *staging* \u2014 ${{ job.status == ''success'' │ │ 43 && ''\u2705 Succeeded'' || ''\u274c Failed'' }}\nRepo: ${{ github.repository │ │ 44 }} | Branch: ${{ github.ref_name }} | By: ${{ github.actor }}"}' │ │ 45 env: │ │ 46 SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} │ │ 47 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────── 🔍 DRY RUN — deploy-production.yml ───────────────────────────────────────╮ │ 1 name: '[my-web-app] Deploy to Production' │ │ 2 'on': │ │ 3 push: │ │ 4 branches: │ │ 5 - main │ │ 6 workflow_dispatch: null │ │ 7 env: │ │ 8 DEPLOY_ENV: production │ │ 9 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} │ │ 10 LOG_LEVEL: WARNING │ │ 11 DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} │ │ 12 jobs: │ │ 13 deploy-production: │ │ 14 name: Deploy to Production │ │ 15 runs-on: ubuntu-latest │ │ 16 environment: production │ │ 17 steps: │ │ 18 - name: 📥 Checkout repository │ │ 19 uses: actions/checkout@v4 │ │ 20 - name: 🐍 Set up Python │ │ 21 uses: actions/setup-python@v5 │ │ 22 with: │ │ 23 python-version: '3.11' │ │ 24 - name: 📦 Cache pip dependencies │ │ 25 uses: actions/cache@v4 │ │ 26 with: │ │ 27 path: ~/.cache/pip │ │ 28 key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} │ │ 29 restore-keys: ${{ runner.os }}-pip- │ │ 30 - name: ⚙️ Install dependencies │ │ 31 run: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi │ │ 32 - name: '🚀 Deploy step 1: ./scripts/deploy.sh --env=$DEPLOY_ENV' │ │ 33 run: ./scripts/deploy.sh --env=$DEPLOY_ENV │ │ 34 - name: '🚀 Deploy step 2: pytest tests/ -v --tb=short -x' │ │ 35 run: pytest tests/ -v --tb=short -x │ │ 36 - name: '🚀 Deploy step 3: curl --fail ${{ secrets.PROD_HEALTH_CHECK_URL }}' │ │ 37 run: curl --fail ${{ secrets.PROD_HEALTH_CHECK_URL }} │ │ 38 - name: '🚀 Deploy step 4: echo ''✅ Production deployment verified''' │ │ 39 run: echo '✅ Production deployment verified' │ │ 40 - name: 💬 Notify Slack │ │ 41 if: always() │ │ 42 uses: slackapi/slack-github-action@v1.27.0 │ │ 43 with: │ │ 44 payload: '{"text": "Deploy to *production* \u2014 ${{ job.status == ''success'' │ │ 45 && ''\u2705 Succeeded'' || ''\u274c Failed'' }}\nRepo: ${{ github.repository │ │ 46 }} | Branch: ${{ github.ref_name }} | By: ${{ github.actor }}"}' │ │ 47 env: │ │ 48 SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} │ │ 49 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Deploy Bot Report — my-web-app [🔍 DRY RUN] ┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Workflow File ┃ Status ┃ Commit / Note ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━┩ │ deploy-dev.yml │ DRY_RUN │ (dry run — no commit) │ ├───────────────────────┼─────────┼───────────────────────┤ │ deploy-staging.yml │ DRY_RUN │ (dry run — no commit) │ ├───────────────────────┼─────────┼───────────────────────┤ │ deploy-production.yml │ DRY_RUN │ (dry run — no commit) │ └───────────────────────┴─────────┴───────────────────────┘
✅ 3/3 workflow(s) previewed successfully.
Section 9 — Advanced: Workflow Templates Library
Reusable Templates for Common Deployment Scenarios
Different technology stacks need different deployment recipes.
A Django app needs database migrations. A static React site just needs to sync files to S3. A Docker service needs to build and push an image.
The template library below provides ready-to-use configurations for the most common scenarios. Each template is a dictionary of parameters that slot directly into deploy_bot().
def get_workflow_template(template_name: str) -> Dict[str, Any]:
"""
Return a pre-configured deploy bot parameter set for a named technology stack.
Each template is a battle-tested configuration for a common deployment
scenario. Pass the returned dict directly to deploy_bot() via **unpacking.
Available Templates
-------------------
'django' — Django/Python web app with Gunicorn + Nginx
'fastapi' — FastAPI/Python REST API with Uvicorn
'react-s3' — React SPA deployed to AWS S3 + CloudFront
'docker' — Dockerised service pushed to a registry and redeployed
'flask' — Flask app with basic Gunicorn deployment
Parameters
----------
template_name : str
One of the template keys listed above.
Returns
-------
dict
Keyword arguments suitable for deploy_bot(), excluding 'creds'.
Raises
------
KeyError
If template_name is not in the known templates.
Example
-------
>>> tmpl = get_workflow_template('django')
>>> report = deploy_bot(creds=creds, **tmpl)
"""
templates = {
# ── Django template ───────────────────────────────────────────────────
"django": {
"project_name" : "django-app",
"base_deploy_command" : (
"python manage.py migrate --no-input && "
"python manage.py collectstatic --no-input && "
"sudo systemctl restart gunicorn"
),
"target_environments" : ["staging", "production"],
"python_version" : "3.11",
},
# ── FastAPI template ──────────────────────────────────────────────────
"fastapi": {
"project_name" : "fastapi-service",
"base_deploy_command" : (
"alembic upgrade head && "
"sudo systemctl restart uvicorn"
),
"target_environments" : ["dev", "staging", "production"],
"python_version" : "3.11",
},
# ── React → S3 template ───────────────────────────────────────────────
# Note: requires AWS credentials as GitHub secrets
"react-s3": {
"project_name" : "react-frontend",
"base_deploy_command" : (
"npm ci && "
"npm run build && "
"aws s3 sync ./build s3://${{ secrets.S3_BUCKET }} --delete && "
"aws cloudfront create-invalidation "
"--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} --paths '/*'"
),
"target_environments" : ["staging", "production"],
"python_version" : "3.11", # Python still needed for YAML gen
},
# ── Docker template ───────────────────────────────────────────────────
# Builds, pushes, and restarts a Docker-based service
"docker": {
"project_name" : "docker-service",
"base_deploy_command" : (
"docker build -t ${{ secrets.REGISTRY }}/${{ github.repository }}:${{ github.sha }} . && "
"docker push ${{ secrets.REGISTRY }}/${{ github.repository }}:${{ github.sha }} && "
"docker compose pull && docker compose up -d"
),
"target_environments" : ["staging", "production"],
"python_version" : "3.11",
},
# ── Flask template ────────────────────────────────────────────────────
"flask": {
"project_name" : "flask-app",
"base_deploy_command" : (
"flask db upgrade && "
"sudo systemctl restart gunicorn-flask"
),
"target_environments" : ["dev", "staging", "production"],
"python_version" : "3.11",
},
}
if template_name not in templates:
available = ", ".join(sorted(templates.keys()))
raise KeyError(
f"Unknown template '{template_name}'. Available: {available}"
)
return templates[template_name]
# ── Demo: show all available templates in a table ────────────────────────────
template_table = Table(title="Available Workflow Templates", show_lines=True)
template_table.add_column("Template", style="cyan", no_wrap=True)
template_table.add_column("Project Name", style="green")
template_table.add_column("Environments", style="yellow")
template_table.add_column("Deploy Command (truncated)", style="white")
for name in ["django", "fastapi", "react-s3", "docker", "flask"]:
tmpl = get_workflow_template(name)
template_table.add_row(
name,
tmpl["project_name"],
", ".join(tmpl["target_environments"]),
tmpl["base_deploy_command"][:55] + "...",
)
console.print(template_table)Available Workflow Templates ┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Template ┃ Project Name ┃ Environments ┃ Deploy Command (truncated) ┃ ┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ django │ django-app │ staging, production │ python manage.py migrate --no-input && python │ │ │ │ │ manage.py... │ ├──────────┼─────────────────┼──────────────────────────┼─────────────────────────────────────────────────────────┤ │ fastapi │ fastapi-service │ dev, staging, production │ alembic upgrade head && sudo systemctl restart │ │ │ │ │ uvicorn... │ ├──────────┼─────────────────┼──────────────────────────┼─────────────────────────────────────────────────────────┤ │ react-s3 │ react-frontend │ staging, production │ npm ci && npm run build && aws s3 sync ./build │ │ │ │ │ s3://${{... │ ├──────────┼─────────────────┼──────────────────────────┼─────────────────────────────────────────────────────────┤ │ docker │ docker-service │ staging, production │ docker build -t ${{ secrets.REGISTRY }}/${{ │ │ │ │ │ github.repo... │ ├──────────┼─────────────────┼──────────────────────────┼─────────────────────────────────────────────────────────┤ │ flask │ flask-app │ dev, staging, production │ flask db upgrade && sudo systemctl restart │ │ │ │ │ gunicorn-fla... │ └──────────┴─────────────────┴──────────────────────────┴─────────────────────────────────────────────────────────┘
Section 10 — Advanced: Listing and Auditing Existing Workflows
Visibility Into What's Already Deployed
A good deploy bot doesn't just push files — it also lets you inspect what's already there.
The function below queries the GitHub API to list all workflow files currently in a repository, giving you a clear audit trail before making changes.
def list_existing_workflows(
owner : str,
repo : str,
headers: Dict[str, str],
) -> List[Dict[str, str]]:
"""
Retrieve a list of all GitHub Actions workflow files in a repository.
Queries the `.github/workflows/` directory via the GitHub Contents API
and returns metadata for every YAML file found. Useful for auditing
what workflows are currently active before running the deploy bot.
Parameters
----------
owner : str — repository owner
repo : str — repository name
headers : dict — GitHub API authentication headers
Returns
-------
list of dict
Each dict contains:
- 'name' : filename (e.g., 'deploy-production.yml')
- 'path' : full path in the repo
- 'sha' : current file SHA
- 'size_bytes' : file size in bytes
- 'html_url' : link to view the file on GitHub
Returns an empty list if the directory does not exist yet.
Example
-------
>>> workflows = list_existing_workflows(owner, repo, headers)
>>> for wf in workflows:
... print(wf['name'], wf['size_bytes'])
"""
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{WORKFLOW_DIR}"
response = requests.get(url, headers=headers, timeout=10)
# 404 means the .github/workflows directory doesn't exist yet — that's fine
if response.status_code == 404:
console.print(
"[yellow]ℹ️ No .github/workflows directory found — repository has no workflows yet.[/yellow]"
)
return []
response.raise_for_status() # Surface other errors
files = response.json()
# Filter to only YAML workflow files (ignore any non-.yml files)
workflows = [
{
"name" : f["name"],
"path" : f["path"],
"sha" : f["sha"],
"size_bytes": f["size"],
"html_url" : f["html_url"],
}
for f in files
if f["name"].endswith((".yml", ".yaml")) and f["type"] == "file"
]
# ── Display results in a table ────────────────────────────────────────────
if workflows:
audit_table = Table(
title=f"Existing Workflows in {owner}/{repo}",
show_lines=True,
)
audit_table.add_column("Filename", style="cyan", no_wrap=True)
audit_table.add_column("Size", style="yellow")
audit_table.add_column("SHA (short)",style="green")
for wf in workflows:
audit_table.add_row(
wf["name"],
f"{wf['size_bytes']} B",
wf["sha"][:10] + "...",
)
console.print(audit_table)
else:
console.print("[yellow]ℹ️ No YAML workflow files found in .github/workflows/[/yellow]")
return workflows
print("✅ list_existing_workflows() defined.")
print(" To use it, call: list_existing_workflows(creds['owner'], creds['repo'], creds['headers'])")✅ list_existing_workflows() defined. To use it, call: list_existing_workflows(creds['owner'], creds['repo'], creds['headers'])
Section 11 — Advanced: CLI-Style Interactive Interface
Making the Bot User-Friendly
Power users run tools from the command line.
This section wraps the deploy bot in a simple interactive menu so anyone can use it without editing code directly.
It also serves as a model for how to turn any notebook into a CLI-style tool.
def run_interactive_deploy_bot() -> None:
"""
Launch an interactive CLI-style interface for the GitHub Actions deploy bot.
Guides the user through:
1. Connecting to a GitHub repository
2. Choosing a workflow template or custom configuration
3. Selecting target environments
4. Running in dry-run or live mode
This function is a thin wrapper around the other functions in this
notebook, designed to make the bot accessible without editing code.
Parameters
----------
None
Returns
-------
None (side-effect: interactive prompts and output to console)
Example
-------
>>> run_interactive_deploy_bot()
"""
console.print(Panel(
"[bold]GitHub Actions Deploy Bot[/bold]\n"
"This wizard will create deployment workflow files in your repository.\n"
"Type [cyan]Ctrl+C[/cyan] at any time to cancel.",
title="🤖 Deploy Bot Wizard",
border_style="blue"
))
# ── Step 1: Connect ────────────────────────────────────────────────────────
console.print("\n[bold cyan]Step 1/4 — Connect to GitHub[/bold cyan]")
creds = setup_github_credentials()
# ── Step 2: Choose template or custom ─────────────────────────────────────
console.print("\n[bold cyan]Step 2/4 — Choose Configuration[/bold cyan]")
console.print("Available templates: [green]django, fastapi, react-s3, docker, flask[/green]")
console.print("Or type [yellow]custom[/yellow] to enter a deploy command manually.")
choice = input("\nTemplate or 'custom': ").strip().lower()
if choice == "custom":
project_name = input("Project name (letters, numbers, hyphens only): ").strip()
deploy_cmd = input("Deploy command: ").strip()
template_args = {
"project_name" : project_name,
"base_deploy_command": deploy_cmd,
}
else:
template_args = get_workflow_template(choice)
console.print(f"[green]✅ Loaded template: {choice}[/green]")
# ── Step 3: Choose environments ────────────────────────────────────────────
console.print("\n[bold cyan]Step 3/4 — Select Environments[/bold cyan]")
console.print("Options: [green]dev, staging, production[/green] (comma-separated)")
env_input = input("Environments [default: dev,staging,production]: ").strip()
if env_input:
# Parse comma-separated input, stripping whitespace from each value
target_envs = [e.strip() for e in env_input.split(",") if e.strip()]
else:
target_envs = ["dev", "staging", "production"]
template_args["target_environments"] = target_envs
# ── Step 4: Dry run or live? ───────────────────────────────────────────────
console.print("\n[bold cyan]Step 4/4 — Choose Mode[/bold cyan]")
mode_input = input("Run mode — [D]ry run (preview only) or [L]ive deploy? [D/L]: ").strip().upper()
dry_run = mode_input != "L" # Default to dry run unless explicitly 'L'
if not dry_run:
confirm = input(
f"⚠️ This will COMMIT files to {creds['owner']}/{creds['repo']}. "
"Type YES to confirm: "
).strip()
if confirm != "YES":
console.print("[yellow]Cancelled. Running as dry run instead.[/yellow]")
dry_run = True
# ── Execute ────────────────────────────────────────────────────────────────
deploy_bot(
creds = creds,
dry_run = dry_run,
**template_args,
)
# Uncomment to launch the interactive wizard:
# run_interactive_deploy_bot()
print("✅ run_interactive_deploy_bot() defined.")
print(" Uncomment the last line to launch the wizard.")✅ run_interactive_deploy_bot() defined. Uncomment the last line to launch the wizard.
Conclusion
This notebook has demonstrated how to build a powerful and flexible GitHub Actions deploy bot using Python. You've learned to:
- Securely handle credentials with
getpassand environment variables. - Programmatically generate GitHub Actions YAML workflows.
- Implement multi-environment deployments (dev, staging, production) with tailored settings.
- Interact with the GitHub API to create and update workflow files.
- Add robust features like dry runs, rollback on error, and detailed reporting.
- Create reusable workflow templates for common technology stacks.
- Build an interactive CLI-style interface for user-friendly operation.
By automating the creation and management of GitHub Actions workflows, this bot streamlines your CI/CD pipeline, reduces human error, and ensures consistent deployment practices across all your projects.